From 1880c6d0cd06f1728cf9f159e41c8e3346d577fd Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Sat, 29 Aug 2026 20:16:41 +0200 Subject: [PATCH 1/2] fix(sync): stop receive-side mutation before conflict loss (#150) Make protected receive-side folders fail closed before recovery, configuration, scan, request, restart, or database mutations while preserving authenticated remote Need state and normal SendOnly behavior. Recovery remains inspection-only, and the four approved bridge operations use exact one-shot capabilities. What could go wrong and why this is safe: a broad configuration bypass or late startup write could mutate protected state before the safety stop. Guards are default-deny, capabilities bind the exact folder, operation, diff, and device where applicable, database aliases are preflighted before open, and protected startup waits until the model has consumed its initial configuration. Not verified: dedicated ephemeral Linux ENOSPC and capacity-grow containment; candidate PR CI; a fresh XCFramework, final archive, and physical-device checks after owner-confirmed merge. Full race runs remain red only for separately tracked #152, and the upstream versioner external fixture remains unexecutable because its script lacks execute permission. Refs #150 and #167. --- .github/workflows/ci.yml | 56 + CHANGELOG.md | 7 +- ...keep-both-never-replaces-existing-files.md | 1 + .../028-conflicts-require-manual-choice.md | 2 +- ...nflict-retention-stops-before-byte-loss.md | 14 + ...33-conflict-recovery-is-inspection-only.md | 10 + ...034-app-private-database-trust-boundary.md | 10 + docs/sync-filters-ux.md | 130 +- go/bridge/conflicts.go | 234 +- go/bridge/conflicts_test.go | 938 +-- go/bridge/devices.go | 4 +- go/bridge/events_test.go | 92 + go/bridge/folders.go | 113 +- go/bridge/folders_test.go | 77 +- go/bridge/folderscan_test.go | 12 +- go/bridge/folderstatus.go | 146 +- go/bridge/folderstatus_test.go | 315 +- go/bridge/issue150_bridge_restart_test.go | 335 + go/bridge/issue150_capability_test.go | 458 ++ go/bridge/issue150_configuration_test.go | 699 +++ go/bridge/issue150_database_open_test.go | 297 + go/bridge/pendingfolders.go | 28 +- go/bridge/pendingfolders_test.go | 65 +- go/bridge/rescan_migration_test.go | 6 +- go/bridge/status.go | 62 +- go/bridge/syncthing.go | 83 +- go/patches/README.md | 8 + ...ue-150-loss-aware-conflict-retention.patch | 5440 +++++++++++++++++ ios/VaultSync/App/AppDelegate.swift | 26 +- ios/VaultSync/App/UIAuditFixture.swift | 2 - ios/VaultSync/App/VaultSyncApp.swift | 13 +- ios/VaultSync/Models/SyncUserError.swift | 61 +- .../Services/BackgroundSyncService.swift | 400 +- .../Services/ConflictSafetyPolicy.swift | 132 + .../DiagnosticsPairingController.swift | 12 +- .../Services/FolderPathReconciler.swift | 19 +- .../Services/SyncBridgeService.swift | 52 +- ios/VaultSync/Services/SyncthingManager.swift | 823 ++- .../ViewModels/ObsidianReconnectFlow.swift | 26 +- .../ViewModels/SetupChecklistViewModel.swift | 41 +- .../ViewModels/ShareAcceptCoordinator.swift | 87 +- .../ViewModels/SyncHeaderModel.swift | 10 +- ios/VaultSync/Views/ConflictDiffView.swift | 386 +- ios/VaultSync/Views/ConflictListView.swift | 54 +- ios/VaultSync/Views/ContentView.swift | 315 +- .../Views/ControlledDiagnosticsView.swift | 59 +- ios/VaultSync/Views/IgnorePatternsView.swift | 42 +- ios/VaultSync/Views/OnboardingView.swift | 117 +- ios/VaultSync/Views/PendingSharesView.swift | 107 +- ios/VaultSync/Views/RelayHomeView.swift | 8 +- ios/VaultSync/Views/SettingsView.swift | 4 +- .../Views/SyncFilterRecommendationSheet.swift | 93 +- ios/VaultSync/Views/SyncIssuesView.swift | 84 +- ios/VaultSync/de.lproj/InfoPlist.strings | 2 +- ios/VaultSync/de.lproj/Localizable.strings | 160 +- ios/VaultSync/en.lproj/InfoPlist.strings | 2 +- ios/VaultSync/en.lproj/Localizable.strings | 160 +- ios/VaultSync/es.lproj/InfoPlist.strings | 2 +- ios/VaultSync/es.lproj/Localizable.strings | 160 +- ios/VaultSync/zh-Hans.lproj/InfoPlist.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 162 +- .../BackgroundSyncReasonTests.swift | 42 +- .../BackgroundWidgetStatusTests.swift | 45 +- ...flictRetentionSafetyIntegrationTests.swift | 1361 +++++ .../ConflictSafetyPolicyTests.swift | 116 + ...osticsControlledDownloadRuntimeTests.swift | 4 +- ...gnosticsForegroundUploadRuntimeTests.swift | 15 +- .../FirstSyncDetectionTests.swift | 25 +- .../FolderPathReconcilerTests.swift | 14 + ios/VaultSyncTests/Issue95GuidanceTests.swift | 20 +- .../ObsidianReconnectFlowTests.swift | 33 +- .../SecurityScopedLeaseTakeoverTests.swift | 3 +- .../SetupChecklistViewModelTests.swift | 56 +- .../ShareAcceptCoordinatorTests.swift | 53 + ios/VaultSyncTests/SyncHeaderModelTests.swift | 12 +- ios/VaultSyncTests/SyncUserErrorTests.swift | 80 +- .../WidgetCompletionWriteTests.swift | 32 +- .../WidgetSnapshotStatusTests.swift | 6 +- ios/project.yml | 2 +- 79 files changed, 12519 insertions(+), 2665 deletions(-) create mode 100644 docs/decisions/032-conflict-retention-stops-before-byte-loss.md create mode 100644 docs/decisions/033-conflict-recovery-is-inspection-only.md create mode 100644 docs/decisions/034-app-private-database-trust-boundary.md create mode 100644 go/bridge/issue150_bridge_restart_test.go create mode 100644 go/bridge/issue150_capability_test.go create mode 100644 go/bridge/issue150_configuration_test.go create mode 100644 go/bridge/issue150_database_open_test.go create mode 100644 go/patches/syncthing/004-issue-150-loss-aware-conflict-retention.patch create mode 100644 ios/VaultSync/Services/ConflictSafetyPolicy.swift create mode 100644 ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift create mode 100644 ios/VaultSyncTests/ConflictSafetyPolicyTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0fbda2..b8dddcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,62 @@ jobs: # separate runner needed. run: go test -tags noassets ./bridge -count=1 + - name: Run patched Syncthing conflict-safety tests + working-directory: go + run: | + expected="$(cat <<'EOF' + TestIssue150ConcurrentCapabilityReuseAllowsExactlyOneQueuedUse + TestIssue150ConfigStartupPreflightRunsBeforeUpgradeSave + TestIssue150ExactProtectedCapabilitiesAuthorizeOnlyTheirBoundDiffs + TestIssue150FolderDatabaseNameValidationPreservesHistoricalCanonicalASCII + TestIssue150GenericDeviceRemovalCannotDeriveProtectedMembershipDiff + TestIssue150GenericProtectedConfigurationDiffsStopBeforeSubscribers + TestIssue150GenericProtectedRemovalStopsInEitherFolderOrder + TestIssue150NoOpCapabilityUseIsConsumed + TestIssue150ProtectedAppStartupSkipsGlobalMutationServices + TestIssue150ProtectedCapabilitiesAreOneShotAndNonTransferable + TestIssue150ProtectedCapabilitiesRejectAdditionalOrReorderedDiffs + TestIssue150ProtectedDatabaseOpenDoesNotCleanOrphanDatabaseArtifacts + TestIssue150ProtectedFullAndDeltaPersistAuthenticatedNeed + TestIssue150ProtectedIntroducerDiffDoesNotLogLabelBeforeConfigGuard + TestIssue150ProtectedRequestKeepsReadsButDoesNotRecheckOrLeak + TestIssue150ProtectedRunnerIgnoresAtomicReplaceDuringRemoteIndex + TestIssue150ProtectedRunnerStartsWithoutMarkerOrLocalIndex + TestIssue150ProtectedSendReceiveRemoteIndexPreservesLocalStateForEveryMaxConflictsValue + TestIssue150ProtectedShareCapabilityRejectsPrepareDerivedAdditionalDiff + TestIssue150ProtectedVersionerIsPrivateAndInspectionOnly + TestIssue150ProtectionIsOptInAndCapabilityRequiresEnabledWrapper + TestIssue150PureSendOnlyAppKeepsStandardStartupServices + TestIssue150PureSendOnlyDatabaseOpenKeepsExistingWriteSemantics + TestIssue150ReceiveSideDatabaseOptionStopsBeforePathCreation + TestIssue150ReceiveSideDatabasePreflightNormalizesLateMutatingMainOpenFailure + TestIssue150ReceiveSideDatabasePreflightRejectsExistingPhysicalAliasesBeforeMutation + TestIssue150ReceiveSideDatabasePreflightRejectsFutureCaseFoldAliasesBeforeMutation + TestIssue150ReceiveSideDatabasePreflightRejectsNoncanonicalRegisteredNamesBeforeMutation + TestIssue150ReceiveSideDatabasePreflightRejectsRecognizableDatabaseDeviationsBeforeMutation + TestIssue150ReceiveSideDatabasePreflightRejectsUncheckpointedAppOwnedWALWithoutMutation + TestIssue150ReceiveSideDatabaseSafetyStopReusesCanonicalConfigError + TestIssue150ReceiveSideReadOnlyDisablesAutoAcceptBeforeConfigOrFilesystem + TestIssue150SendOnlyGenericConfigurationRetainsExistingSemantics + TestIssue150ZeroValueCapabilityFailsClosed + EOF + )" + discovered="$(go test -mod=readonly \ + github.com/syncthing/syncthing/internal/db/sqlite \ + github.com/syncthing/syncthing/lib/config \ + github.com/syncthing/syncthing/lib/model \ + github.com/syncthing/syncthing/lib/syncthing \ + github.com/syncthing/syncthing/lib/versioner \ + -list '^TestIssue150[A-Z]' | grep '^TestIssue150[A-Z]' | LC_ALL=C sort -u)" + diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$discovered") + go test -mod=readonly \ + github.com/syncthing/syncthing/internal/db/sqlite \ + github.com/syncthing/syncthing/lib/config \ + github.com/syncthing/syncthing/lib/model \ + github.com/syncthing/syncthing/lib/syncthing \ + github.com/syncthing/syncthing/lib/versioner \ + -run '^TestIssue150[A-Z]' -count=1 -timeout=15m + notify-tests: name: Notify Tests runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 2953231..3bdfedf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,14 @@ All notable changes to VaultSync are documented here. ### Fixed +- **Receive-capable vaults now stop before automatic local changes** ([#150](https://github.com/psimaker/vaultsync/issues/150), [#167](https://github.com/psimaker/vaultsync/issues/167)) — In 2.0.2, Send & Receive, Receive Only, and Receive Encrypted vaults retain remote change information for inspection but do not scan, watch, download, clean versions, or change local vault/index data; Send Only keeps its existing behavior and explicit Sync Filters. Explicit pause, sharing, unsharing, and removal remain available, while path, filter, and rescan changes stay stopped for receive-capable vaults. If internal database validation cannot prove a clean recognized state, VaultSync stops instead of repairing it automatically. VaultSync lets you inspect whichever conflict copies are still available, but Keep This, Keep Other, Keep Both, and Always Skip cannot rename, replace, delete, ignore, or rescan conflict files. Creating a new vault or accepting a new shared vault is unavailable in 2.0.2. Reopening an existing vault performs no delayed default-filter write or rescan. - **Cloud Relay no longer continues from malformed saved Relay device IDs** ([#161](https://github.com/psimaker/vaultsync/issues/161)) — VaultSync reports that Cloud Relay provisioning did not complete and sends no provisioning request, without rewriting or deleting the stored value. Valid existing JSON and legacy records remain unchanged. - **Push and Cloud Relay registration details now survive failed secure-storage updates** ([#148](https://github.com/psimaker/vaultsync/issues/148)) — VaultSync keeps the last valid value when a replacement cannot be saved and reports the failure instead of continuing as if registration succeeded. - **Folder access now stays intact when reconnecting or syncing in the background** ([#147](https://github.com/psimaker/vaultsync/issues/147)) — reselecting the same Obsidian folder no longer accumulates access claims. Switching folders takes effect only after the new location is readable, scanned, and its permission is saved; any failure keeps the previous folder connected. Background runs release only their own access on completion, restart, or cancellation. - **Background sync no longer reports unfinished work as completed** ([#146](https://github.com/psimaker/vaultsync/issues/146)) — continued processing now reports success only after every expected vault is confirmed fully idle. If the sync engine stops, vault status cannot be read, a vault reports an error, the run expires or is cancelled, or the app returns to the foreground, the background run reports failure instead; conflict checks happen only after idle is proven. -- **Conflicting Obsidian settings now wait for your decision** ([#145](https://github.com/psimaker/vaultsync/issues/145)) — VaultSync no longer automatically deletes, replaces, or promotes `.obsidian` conflict copies by modification time. The legacy preference stays stored but cannot re-enable the retired behavior, and detected conflicts remain visible for manual review. Syncthing's separate conflict-copy retention is not guaranteed. -- **Keep Both no longer overwrites an existing conflict copy** ([#144](https://github.com/psimaker/vaultsync/issues/144)) — when the intended copy name is already occupied, VaultSync leaves all existing files untouched instead of replacing previously saved bytes. -- **Manual conflict resolution preserves unrelated temporary files** ([#143](https://github.com/psimaker/vaultsync/issues/143)) — choosing the conflicting version no longer reuses or overwrites a pre-existing temporary file next to the note. +- **Conflicting Obsidian settings now wait for your decision** ([#145](https://github.com/psimaker/vaultsync/issues/145)) — VaultSync no longer automatically deletes, replaces, or promotes `.obsidian` conflict copies by modification time. The legacy preference stays stored but cannot re-enable the retired behavior, and detected conflicts remain visible for inspection without an executable recovery action in 2.0.2. +- **Keep Both cannot overwrite an existing conflict copy** ([#144](https://github.com/psimaker/vaultsync/issues/144)) — the earlier no-replace safeguard remains a minimum for any future recovery, but 2.0.2 disables Keep Both before it inspects or creates a destination. +- **Conflict review does not touch unrelated temporary files** ([#143](https://github.com/psimaker/vaultsync/issues/143)) — choosing a version is unavailable in 2.0.2 and stops before any temporary-file access. ### Security diff --git a/docs/decisions/027-keep-both-never-replaces-existing-files.md b/docs/decisions/027-keep-both-never-replaces-existing-files.md index d3f4934..91d8aa5 100644 --- a/docs/decisions/027-keep-both-never-replaces-existing-files.md +++ b/docs/decisions/027-keep-both-never-replaces-existing-files.md @@ -2,6 +2,7 @@ - Context: `KeepBothConflict` derived one destination name and could replace an existing regular file at that destination (#144), losing a previously preserved conflict copy. - Decision: Keep Both uses atomic no-replace semantics. Existing destination bytes are never replaced; a collision either produces an actually unique destination or returns an error, and success means every involved content remains present. +- Current boundary: In 2.0.2 decision 033 supersedes executable Keep Both with a non-mutating compatibility stub; this no-replace rule remains a minimum requirement if recovery is ever reintroduced. - Why: “Keep Both” is a preservation promise. A crash or I/O failure between steps must prefer an extra copy over lost bytes, and cleanup never deletes user files automatically. - Rejected alternative: Checking with `Stat`/`fileExists` before a normal rename, because another operation can occupy the destination between check and rename; also rejected overwrite-then-repair, because overwritten bytes cannot be reconstructed safely. - Links: issue #144; `go/bridge/conflicts.go`, `go/bridge/conflicts_test.go`. diff --git a/docs/decisions/028-conflicts-require-manual-choice.md b/docs/decisions/028-conflicts-require-manual-choice.md index aa57165..bb08863 100644 --- a/docs/decisions/028-conflicts-require-manual-choice.md +++ b/docs/decisions/028-conflicts-require-manual-choice.md @@ -5,5 +5,5 @@ - Compatibility: The legacy preference remains stored but is ignored, and the exported `AutoResolveStateConflicts` bridge entry point remains as a non-mutating compatibility no-op. - Why: Modification time cannot establish user intent, especially with clock skew, and a silent choice can propagate an unwanted result to every peer. - Rejected alternative: Keep opt-out last-writer-wins, because a missing or persisted `true` value would continue authorizing mutation without a decision at the time of conflict. -- Boundary: Manual conflict actions remain available after explicit confirmation; Syncthing's separate conflict-copy retention is unchanged and not guaranteed indefinitely. +- Boundary: All automatic conflict handling follows decision 032; in 2.0.2 all explicit recovery entry points follow the inspection-only boundary in decision 033. - Links: issue [#145](https://github.com/psimaker/vaultsync/issues/145); `go/bridge/conflicts.go`; `ios/VaultSync/Services/SyncthingManager.swift`; `ios/VaultSync/Services/BackgroundSyncService.swift`. diff --git a/docs/decisions/032-conflict-retention-stops-before-byte-loss.md b/docs/decisions/032-conflict-retention-stops-before-byte-loss.md new file mode 100644 index 0000000..bd0f0e5 --- /dev/null +++ b/docs/decisions/032-conflict-retention-stops-before-byte-loss.md @@ -0,0 +1,14 @@ +# 032 — Receive-side sync is read-only in 2.0.2 + +- Context: Receive-side automation can otherwise delete or replace a unique local version before conflict recovery is safe (#150/#167). +- Decision: In 2.0.2, existing send-receive, receive-only, and receive-encrypted folders are read-only from database open through runtime; Send Only retains its existing behavior. +- Runtime boundary: Protected folders do not scan, watch, pull remote data, recheck received paths, clean versions, mutate vault metadata or bytes, or mutate their local file index; authenticated peer reads remain available. +- Protocol boundary: Authenticated remote indexes may persist Need and change only its derived local global/needed flags and resulting count buckets; required structural protocol metadata remains allowed. +- Startup boundary: A pending protected-folder database migration stops before mutation, unclassified orphan databases remain untouched, and each existing folder type is immutable for the process lifetime. +- Signal requirement: Rejected operations expose one stable path-free safety code, omit folder, path, vault, device, sentinel, and error details, and never take a success shape. +- Restart requirement: The same home preserves identity, configuration, remote Need, protected vault bytes, and the safety stop without repair, migration, reacceptance, or automatic pause. +- Why: The boundary stops VaultSync- and Syncthing-owned receive mutations before they can race external vault editors or atomic saves; the separate app-private database assumption is recorded in decision 034. +- Rejected alternative: Conflict-shaped path rechecks, rename-first retention, or retain-until-limit, because none establishes a mutation barrier before user choice. +- Rejected alternative: Automatic quarantine, snapshot, repair, migration, or pause, because their safety and override semantics are not proven. +- Recovery boundary: Decision 033 keeps 2.0.2 inspection-only; mutating recovery requires a separately approved and proven doctrine. +- Links: issues [#150](https://github.com/psimaker/vaultsync/issues/150) and [#167](https://github.com/psimaker/vaultsync/issues/167); decisions 002, 004, 027, 028, 033, and 034. diff --git a/docs/decisions/033-conflict-recovery-is-inspection-only.md b/docs/decisions/033-conflict-recovery-is-inspection-only.md new file mode 100644 index 0000000..1c684f3 --- /dev/null +++ b/docs/decisions/033-conflict-recovery-is-inspection-only.md @@ -0,0 +1,10 @@ +# 033 — Conflict recovery is inspection-only in 2.0.2 + +- Context: Existing recovery actions can delete, replace, rename, ignore, or rescan conflict files without a proven byte-preserving recovery doctrine (#150/#167). +- Decision: `ResolveConflict`, `KeepBothConflict`, and `RemoveConflictFilesForOriginal` retain their ABI signatures but return one stable, path-free recovery-unavailable error before any runtime, filesystem, temporary-file, database, filter, or rescan access. +- UI: Conflict copies still present can be inspected, but 2.0.2 makes no retention guarantee and exposes no executable recovery, retry, skip, confirmation, or success flow. +- Scope: `AutoResolveStateConflicts` remains non-mutating; no folder pause, configuration rewrite, persisted-state migration, or automatic reacceptance is introduced. +- Why: Inspection adds no recovery mutation, while an unproven action can silently propagate lost bytes to every peer. +- Rejected alternative: Rename-, quarantine-, snapshot-, or atomic-exchange recovery without a separately approved doctrine and proof. +- Re-entry: Mutating recovery requires separate owner approval plus collision, capacity, race, crash, restart, and two-node convergence evidence. +- Links: issues [#150](https://github.com/psimaker/vaultsync/issues/150) and [#167](https://github.com/psimaker/vaultsync/issues/167); decisions 002, 027, 028, and 032. diff --git a/docs/decisions/034-app-private-database-trust-boundary.md b/docs/decisions/034-app-private-database-trust-boundary.md new file mode 100644 index 0000000..b616964 --- /dev/null +++ b/docs/decisions/034-app-private-database-trust-boundary.md @@ -0,0 +1,10 @@ +# 034 — App-private database ownership in 2.0.2 + +- Context: The 2.0.2 receive-side hard floor must define which writers can reach the embedded engine database (#150/#167). +- Decision: Supported iOS operation keeps configuration and database files in VaultSync's private app container, outside security-scoped vaults, and opens them through one main-app engine owner; the widget has no engine or database access. +- Validation: Recognizable schema, folder identity, alias, or integrity deviations stop with the stable path-free safety code before mutation. +- Configuration: Existing protected folders permit only one-shot folder-, operation-, and where applicable device-bound Remove, Pause, Share, or Unshare diffs; every extra diff and all path, filesystem, type, ignore, or rescan changes remain denied. +- Boundary: Vault files and external vault editors remain inside the receive-side protection model; the internal database assumption applies only to the app-exclusive production path above. +- Why: The product has no helper, extension, second engine process, file-sharing route, or supported external workflow that writes the internal engine directory. +- Rejected alternative: Add database authentication, quarantine, snapshots, persistence migration, or transactional recovery to the containment release; generation-bound versions of those designs belong to Vision 3.0. +- Links: issues [#150](https://github.com/psimaker/vaultsync/issues/150) and [#167](https://github.com/psimaker/vaultsync/issues/167); decisions 032 and 033. diff --git a/docs/sync-filters-ux.md b/docs/sync-filters-ux.md index e402e9b..8962b9d 100644 --- a/docs/sync-filters-ux.md +++ b/docs/sync-filters-ux.md @@ -1,8 +1,8 @@ # Sync Filters — UX Spec > **Internal design reference — not user documentation.** This captures the rationale, layout, and trade-offs behind the Sync Filters feature for maintainers extending it. -> Status: **implemented** (issue [#1](https://github.com/psimaker/vaultsync/issues/1), shipped in v1.2.0; Conflict→Skip extended to Skip Family in v1.3.2, issue [#8](https://github.com/psimaker/vaultsync/issues/8); automatic `.obsidian` conflict resolution added in v1.7.0 and retired under issue [#145](https://github.com/psimaker/vaultsync/issues/145), §6.6; multi-line paste + order-preserving filter writes in v1.7.1, issue [#43](https://github.com/psimaker/vaultsync/issues/43), §6.7). Current shipped app: v2.0.1. -> Last updated: 2026-08-16 +> Status: **implemented** (issue [#1](https://github.com/psimaker/vaultsync/issues/1), shipped in v1.2.0; Conflict→Skip extended to Skip Family in v1.3.2, issue [#8](https://github.com/psimaker/vaultsync/issues/8); automatic `.obsidian` conflict resolution added in v1.7.0 and retired under issue [#145](https://github.com/psimaker/vaultsync/issues/145), §6.6; the still-open fail-closed release requirement is tracked under issues [#150](https://github.com/psimaker/vaultsync/issues/150) and [#167](https://github.com/psimaker/vaultsync/issues/167), §6 and §6.6; multi-line paste + order-preserving filter writes in v1.7.1, issue [#43](https://github.com/psimaker/vaultsync/issues/43), §6.7). Current shipped app: v2.0.1. +> Last updated: 2026-08-28 This document is the design reference for the Sync Filters feature — the UI for excluding files and folders from sync requested in issue #1 by @vitaly74. It captures the rationale behind the layout, preset catalog, migration path, and multi-vault behavior; refer to it when extending or modifying the feature. @@ -10,7 +10,7 @@ This document is the design reference for the Sync Filters feature — the UI fo ## 1. Why -The Syncthing engine already supports per-folder ignore patterns via `.stignore` files. VaultSync's Go bridge already exposes them (`GetFolderIgnores`, `SetFolderIgnores`). What's missing is the **UI** — without it, users can't see, add, or remove patterns from inside the app. +The Syncthing engine supports per-folder ignore patterns via `.stignore` files, exposed by VaultSync's Go bridge through `GetFolderIgnores` and `SetFolderIgnores`. In 2.0.2, only existing Send Only folders may read or edit Sync Filters; receive-capable and unknown folder modes stop before filter access or mutation. The goal isn't to expose raw Syncthing pattern syntax. The goal is **"keep this off my iPhone"** in plain language. Most users don't know what a glob pattern is, but they do know that `.git` is taking 45 MB and they don't need it on mobile. @@ -21,13 +21,13 @@ Per-vault, on the existing vault detail screen. A new `Sync Filters` link appear ``` Vault (name, path) Sync Status (state, completion, errors) -Conflicts (when present) -► Sync Filters ← new +Conflicts (inspection only when present) +► Sync Filters (editable for Send Only only in 2.0.2) Shared With (devices) -Rescan Vault +Rescan Vault (Send Only only in 2.0.2) ``` -Position is intentional: filters are configuration, sharing/rescan are actions. Right after Conflicts means a user who just resolved a `workspace.json` conflict sees the link to "stop this from happening again" immediately below. +Position is intentional: filters are configuration, sharing/rescan are actions. Right after Conflicts keeps prevention controls near the copies a user has just inspected, without implying that 2.0.2 can resolve those conflicts. ## 3. The screen — `IgnorePatternsView` @@ -63,13 +63,13 @@ Position is intentional: filters are configuration, sharing/rescan are actions. Five sections, each rendered as a `List` section: -1. **Recommended** — always visible. Workspace state + Trash, both ON by default for new vaults. +1. **Recommended** — always visible. Workspace state + Trash are preselected in the first-run recommendation sheet; the regular list reflects only patterns actually stored in `.stignore`. 2. **Found in this vault** — only renders when the vault scan returned results. Shows actual byte size + file count for each detected heavy folder. The scanner checks both the sync folder root and one level deep (the typical "Obsidian root with vault subdirs" layout) and aggregates matches per pattern (e.g. ".git in 3 vaults — 127 MB total"). The most persuasive piece of UI. 3. **Other presets** — every preset that isn't already in Recommended or Found. 4. **Custom patterns** — anything in `.stignore` that isn't part of any preset. User can swipe-to-delete or add a new line. 5. **Footer** — link to the Syncthing pattern docs for power users. -All toggles write through to `.stignore` immediately. No save button. +For an existing Send Only folder, toggles write through to `.stignore` immediately. There is no save button. Receive-capable and unknown folders expose no filter read or write path in 2.0.2, and their rescan actions remain disabled. ## 4. Preset catalog @@ -107,40 +107,38 @@ Two presets that the issue thread mentioned but I'm **not** including in the ini └─────────────────────────────────────────┘ ``` -Shown the **first time** a user opens a vault's detail screen, per vault. Persisted via a `UserDefaults` array of folder IDs that have been shown. +Shown the **first time** a user opens an existing Send Only vault's detail screen, per vault. It is not shown for receive-capable or unknown folders. The shown-state is persisted via a `UserDefaults` array of folder IDs. -- **Done** — applies the checked presets/patterns to `.stignore` and dismisses. +- **Done** — applies the checked presets/patterns to the Send Only folder's `.stignore` and dismisses. - **Skip** — dismisses without changing `.stignore`. The folder is still marked as "seen", so the sheet won't reappear. - Detected heavy folders are pre-checked but the user can uncheck before applying. -The Recommended set is also auto-applied silently when a new folder is added (so a fresh vault never syncs `workspace.json` even if the user instantly closes the sheet without tapping Done). +New vault creation and share acceptance are unavailable in 2.0.2. Loading an +existing folder at startup never writes a filter automatically. For an existing +Send Only folder, the preselected Recommended set reaches `.stignore` only when +the user taps **Done**. This avoids a delayed `.stignore` write and scheduled +scan racing a conflict safety stop; **Skip** remains non-mutating. ## 6. Conflict → Ignore -In `ConflictDiffView`, a toolbar menu appears (top-right `⋯`): - -```text -⋯ menu -└─ Always skip on this iPhone -``` - -Tapping it performs a **Skip Family** action (added in v1.3.2, see issue [#8](https://github.com/psimaker/vaultsync/issues/8)): - -1. Writes a *pair* of patterns to `.stignore`: the file's exact relative path and a matching `.sync-conflict-*` glob. -2. Deletes any sync-conflict copies of that file currently on disk. -3. Rescans the folder and refreshes the conflict cache so the conflict disappears from the home-screen Sync Issues list immediately. - -Confirmation alert: - -> "`'.obsidian/plugins/dataview/cache.db'` and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." - -If existing conflict copies were removed, a second line is appended: - -> "2 existing conflict copies were removed." - -Reasoning behind the family approach: the v1.2.0 design used an exact-path pattern for predictability, but that left a hole — a fresh `sync-conflict-…` copy with a new timestamp would arrive from the desktop and the conflict reappeared. Pairing the original path with the conflict-copy glob makes "skip" actually mean skip, without sacrificing predictability: the two `.stignore` lines are still plain, no smart-glob heuristics, no hidden state. In the Sync Filters list the pair is presented as a single row with a `+ conflict copies` caption. - -The original file itself is **not** deleted from disk — only the conflict-copy variants. Users who later want to revert can swipe-to-delete the row in Sync Filters; both lines are removed atomically. +Versions before 2.0.2 offered **Always skip on this iPhone** from +`ConflictDiffView`. That legacy action wrote the original path plus a matching +`.sync-conflict-*` pattern, removed existing conflict copies, and requested +a rescan. It could therefore mutate evidence before a byte-preserving recovery +had been proven. + +In 2.0.2 the conflict view is inspection-only in every engine safety state. +There is no Skip Family menu, confirmation, filter write, conflict removal, +user-triggered rescan, retry prompt, or success summary. The bridge and manager +compatibility entry points return the stable path-free recovery-unavailable +error before accessing the folder or `.stignore`. Normal explicit Sync Filters +remain available only for Send Only folders; receive-capable and unknown modes +stop before filter access or mutation. + +Previously recorded filter pairs remain stored and editable in Sync Filters; +there is no migration or automatic rewrite. Their representation as one row +with a `+ conflict copies` caption remains unchanged. This preserves an explicit +past choice without treating it as consent for a new conflict recovery action. ## 6.5 Multi-vault setups @@ -155,10 +153,10 @@ settings and plugin state that users expect to sync. VaultSync therefore keeps these files in the same explicit conflict workflow as notes instead of choosing a winner automatically: -- **No app-owned automatic mutation.** Foreground and background sync leave - originals and conflict copies unchanged, regardless of their modification - times or whether the original is missing. Detected conflicts inside and - outside `.obsidian` remain available in the manual conflict UI. +- **The app-owned automatic resolver is retired.** Foreground and background + code no longer invoke VaultSync's former modification-time resolver. This + does not claim that the embedded engine retains every copy; the conflict UI + shows only copies that are still available when they are read. - **Legacy state cannot opt back in.** The historical `auto-resolve-state-conflicts-v1` preference remains stored so an update does not delete or reset user preferences, but its value is ignored. A persisted @@ -167,15 +165,35 @@ a winner automatically: `AutoResolveStateConflicts` entry point remains as a gomobile compatibility no-op and makes no filesystem changes. Foreground and background code do not call it. -- **Manual choices remain available.** Keep This, Keep Other, Keep Both, and - Skip Family still run only after the user's explicit decision. +- **Receive-side sync is read-only in 2.0.2.** Existing send-receive, + receive-only, and receive-encrypted folders do not scan, watch, pull, request + file data, clean versions, mutate vault bytes or metadata, or mutate their + local file index. Authenticated remote indexes may persist Need using only + the derived local global/needed flags and resulting count buckets. Send Only + retains its existing behavior. +- **The boundary starts at database open.** Protected folder databases with a + pending migration stop before it runs; unknown orphan databases remain + untouched, and recognizable schema, identity, alias, or integrity deviations + stop before mutation. In supported iOS operation the internal engine database + belongs exclusively to the foreground app process; decision 034 records this + narrow 2.0.2 trust boundary. External vault editors and atomic-save races stay + inside the receive-side protection model. +- **Explicit recovery is globally unavailable in 2.0.2.** Keep This, Keep Other, + Keep Both, Skip Family, and conflict-triggered rescan controls are absent in + every safety state. Their ABI-compatible entry points stop before runtime, + folder, filter, filesystem, temporary-file, database, or rescan access. + A stopped folder is not automatically paused, rewritten, migrated, or + reaccepted. +- **Explicit recovery is a separate design boundary.** The existing path-based + actions are not described as lossless recovery: a source or destination can + change during a rename, capacity can fail, and independently derived names + can collide across peers. A replacement recovery needs separate race, + crash-cutpoint, capacity, restart, and two-node-convergence proof before the + UI may expose it. - **Counts mean files now.** The home banner, vault badges, and notifications - count distinct conflicted files instead of conflict copies — with - `MaxConflicts: 10` a single churn-prone file used to read as "10 conflicts". -- **Retention is a separate Syncthing policy.** VaultSync shows conflict copies - that Syncthing leaves on disk, but does not guarantee that Syncthing retains - any copy indefinitely. This manual-review doctrine does not change - Syncthing's own retention or versioning behavior. + count distinct conflicted files instead of conflict copies. Saved + `MaxConflicts` values remain unchanged, including `0`, positive values, and + `-1`, but none can reactivate receive-side mutation in 2.0.2. ## 6.7 Multi-line paste & order-preserving writes (v1.7.1) @@ -203,19 +221,21 @@ delete button was added. ## 7. Migration For users updating from a current build: -- The 3 silent default patterns (`.Trash`, `.obsidian/workspace.json`, `.obsidian/workspace-mobile.json`) **stay on disk untouched**. -- The new derived state automatically shows "Workspace state" and "Trash" as ON. +- The three previously auto-applied default patterns (`.Trash`, `.obsidian/workspace.json`, `.obsidian/workspace-mobile.json`) **stay on disk untouched**. +- The derived state shows "Workspace state" and "Trash" as ON only when those patterns are already present. - No migration sheet. No disk changes. No surprise. -For new vaults added after this lands: -- Recommended presets are silently applied (same as before — keeps `workspace.json` from generating immediate conflicts). -- The first-run sheet appears on first vault-detail open, with Recommended already checked and any scan results pre-checked. +New vault creation and share acceptance are unavailable in 2.0.2. If a later +release re-enables either flow, no preset or rescan may be applied by a delayed +Add, Accept, or startup task; the first-run sheet may write only after explicit +**Done** consent on an eligible Send Only folder, while **Skip** writes nothing. ## 8. Naming -Throughout the app: +In the 2.0.2 app: - Section title: **"Sync Filters"** -- CTAs and copy: **"Skip on this iPhone"**, **"Always skip on this iPhone"**, **"Choose what gets synced to this iPhone"** +- Send Only filter copy may use **"Choose what gets synced to this iPhone"**. +- Conflict views contain no Skip or Always Skip action. **"Always skip on this iPhone"** is historical terminology documented only in §6. Avoiding: - "Ignore patterns" — Syncthing-jargon, users don't think in patterns diff --git a/go/bridge/conflicts.go b/go/bridge/conflicts.go index 74d8fbd..9323406 100644 --- a/go/bridge/conflicts.go +++ b/go/bridge/conflicts.go @@ -40,6 +40,15 @@ const maxConflictScan = 10000 const keepBothTargetExistsError = "keep both target already exists" +// Conflict recovery is inspection-only until a separately approved +// byte-preserving recovery doctrine exists. Keep this value independent of +// runtime, folder, path, and device state so the ABI cannot leak user data. +const conflictRecoveryUnavailableError = "vaultsync-conflict-recovery-unavailable" + +// Conflict inspection has a separate stable result from a verified empty +// scan. It deliberately carries no folder, path, filename, or driver detail. +const conflictInspectionUnavailableError = "vaultsync-conflict-inspection-unavailable" + // Syncthing's fs.IsTemporary recognizes the .syncthing. prefix, so the scanner // ignores these short-lived files instead of publishing them as vault content. // Keep the pattern independent of the user filename to stay below conservative @@ -87,28 +96,34 @@ func systemKeepBothFileOperations() keepBothFileOperations { } // GetConflictFilesJSON scans the folder's directory for .sync-conflict-* files. -// Returns a JSON array of ConflictFile objects. Stops after scanning maxConflictScan files. +// It returns a JSON array only after a complete bounded walk; an unavailable, +// failed, or truncated inspection returns the stable path-free error instead. func GetConflictFilesJSON(folderID string) string { folders := getFolderConfigs() if folders == nil { - return "[]" + return conflictInspectionUnavailableError } folder, exists := folders[folderID] if !exists { - return "[]" + return conflictInspectionUnavailableError } var conflicts []ConflictFile scanned := 0 + truncated := false - filepath.WalkDir(folder.Path, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() { + walkErr := filepath.WalkDir(folder.Path, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { return nil } scanned++ if scanned > maxConflictScan { + truncated = true return filepath.SkipAll } @@ -127,7 +142,10 @@ func GetConflictFilesJSON(folderID string) string { shortID := matches[3] ext := matches[4] - relPath, _ := filepath.Rel(folder.Path, path) + relPath, err := filepath.Rel(folder.Path, path) + if err != nil { + return err + } dir := filepath.Dir(relPath) originalRel := baseName + ext @@ -144,6 +162,9 @@ func GetConflictFilesJSON(folderID string) string { return nil }) + if walkErr != nil || truncated { + return conflictInspectionUnavailableError + } if conflicts == nil { conflicts = []ConflictFile{} @@ -151,7 +172,7 @@ func GetConflictFilesJSON(folderID string) string { data, err := json.Marshal(conflicts) if err != nil { - return "[]" + return conflictInspectionUnavailableError } return string(data) } @@ -167,26 +188,11 @@ func safePath(folderRoot, relPath string) (string, error) { return cleaned, nil } -// KeepBothConflict renames a conflict file so Syncthing no longer treats it as a conflict, -// preserving both the original and the conflict version as regular files. -// The conflict file is renamed from "name.sync-conflict-DATE-SHORTID.ext" to "name.conflict-SHORTID.ext". -// Returns empty string on success, error message on failure. +// KeepBothConflict is retained for gomobile ABI compatibility. Conflict +// recovery is currently inspection-only, so it never accesses or mutates the +// filesystem and always returns a stable, path-free error. func KeepBothConflict(folderID, conflictFileName string) string { - folders := getFolderConfigs() - if folders == nil { - return "syncthing not running" - } - - folder, exists := folders[folderID] - if !exists { - return "folder not found" - } - - conflictPath, err := safePath(folder.Path, conflictFileName) - if err != nil { - return "invalid path: outside folder root" - } - return keepBothConflictFile(conflictPath, conflictFileName, systemKeepBothFileOperations()) + return conflictRecoveryUnavailableError } func keepBothConflictFile(conflictPath, conflictFileName string, ops keepBothFileOperations) string { @@ -227,77 +233,51 @@ func keepBothConflictFile(conflictPath, conflictFileName string, ops keepBothFil return "" } -// ReadFileContent reads a text file within a folder and returns its content. -// folderID identifies the Syncthing folder; relPath is relative to the folder root. -// Returns the file content on success (may be empty for an empty file). -// Returns a string prefixed with "error:" if the file cannot be read or the path is invalid, -// allowing callers to distinguish read errors from legitimately empty files. +// ReadFileContent reads a text file within a folder and returns a JSON envelope. +// The exported Go signature stays ABI-compatible, while the envelope keeps an +// empty file and legitimate "error:" content distinct from an unavailable +// inspection. Failures expose only the fixed path-free code. func ReadFileContent(folderID, relPath string) string { + type result struct { + Content *string `json:"content,omitempty"` + Error string `json:"error,omitempty"` + } + emit := func(value result) string { + data, err := json.Marshal(value) + if err != nil { + return `{"error":"vaultsync-conflict-inspection-unavailable"}` + } + return string(data) + } + unavailable := func() string { + return emit(result{Error: conflictInspectionUnavailableError}) + } + folders := getFolderConfigs() if folders == nil { - return "error:syncthing not running" + return unavailable() } folder, exists := folders[folderID] if !exists { - return "error:folder not found" + return unavailable() } absPath, err := safePath(folder.Path, relPath) if err != nil { - return "error:invalid path" + return unavailable() } data, err := os.ReadFile(absPath) if err != nil { - return fmt.Sprintf("error:%v", err) + return unavailable() } - return string(data) + content := string(data) + return emit(result{Content: &content}) } -// ResolveConflict resolves a sync conflict for a folder. -// conflictFileName is the relative path of the conflict file within the folder. -// If keepConflict is true, the conflict version replaces the original. -// If keepConflict is false, the conflict file is simply deleted. -// Returns empty string on success, error message on failure. +// ResolveConflict is retained for gomobile ABI compatibility. Conflict +// recovery is currently inspection-only, so it never accesses or mutates the +// filesystem and always returns a stable, path-free error. func ResolveConflict(folderID, conflictFileName string, keepConflict bool) string { - folders := getFolderConfigs() - if folders == nil { - return "syncthing not running" - } - - folder, exists := folders[folderID] - if !exists { - return "folder not found" - } - - conflictPath, err := safePath(folder.Path, conflictFileName) - if err != nil { - return "invalid path: outside folder root" - } - - if _, err := os.Stat(conflictPath); os.IsNotExist(err) { - return "conflict file not found" - } - - if keepConflict { - name := filepath.Base(conflictFileName) - matches := conflictPattern.FindStringSubmatch(name) - if matches == nil { - return "invalid conflict filename" - } - - originalName := matches[1] + matches[4] - originalPath := filepath.Join(filepath.Dir(conflictPath), originalName) - - if err := replaceConflictAndRemoveSource(conflictPath, originalPath, systemConflictFileOperations()); err != nil { - return err.Error() - } - return "" - } - - if err := os.Remove(conflictPath); err != nil { - return fmt.Sprintf("delete conflict file: %v", err) - } - - return "" + return conflictRecoveryUnavailableError } func replaceConflictAndRemoveSource(conflictPath, originalPath string, ops conflictFileOperations) error { @@ -367,102 +347,18 @@ func closeConflictTempAfterError(tempFile conflictTempFile, operation string, op return fmt.Errorf("%s: %w", operation, operationErr) } -// RemoveConflictFilesForOriginal removes every sync-conflict copy of the file -// at originalPath inside the given folder. The original file is NOT touched. +// RemoveConflictFilesForOriginal is retained for gomobile ABI compatibility. +// Conflict recovery is currently inspection-only, so it never accesses or +// mutates the filesystem. // // Returns a JSON string of the form: // // {"removed": , "error": ""} // -// Possible error envelopes: "syncthing not running", "folder not found", -// "invalid path: outside folder root", or "remove : " if an -// individual deletion failed mid-loop. -// // Symmetric with GetConflictFilesJSON's JSON-return style — keeps the gomobile // surface uniform (no tuple returns across the bridge). func RemoveConflictFilesForOriginal(folderID, originalPath string) string { - type result struct { - Removed int `json:"removed"` - Error string `json:"error"` - } - emit := func(r result) string { - data, err := json.Marshal(r) - if err != nil { - return `{"removed":0,"error":"marshal failed"}` - } - return string(data) - } - - folders := getFolderConfigs() - if folders == nil { - return emit(result{Error: "syncthing not running"}) - } - - folder, exists := folders[folderID] - if !exists { - return emit(result{Error: "folder not found"}) - } - - // Validate the original path is inside the folder root. - absOriginal, err := safePath(folder.Path, originalPath) - if err != nil { - return emit(result{Error: "invalid path: outside folder root"}) - } - // Reject paths that resolve to the folder root itself — there is no - // "original file" at the root, and walking its parent would scan - // outside the folder. - if absOriginal == folder.Path { - return emit(result{Error: "invalid path: outside folder root"}) - } - - dir := filepath.Dir(absOriginal) - baseName := filepath.Base(originalPath) - ext := filepath.Ext(baseName) - stem := strings.TrimSuffix(baseName, ext) - // Common prefix of every conflict copy of this file. - conflictPrefix := stem + ".sync-conflict-" - - entries, err := os.ReadDir(dir) - if err != nil { - // If the directory does not exist there are simply no conflicts to remove. - if os.IsNotExist(err) { - return emit(result{Removed: 0}) - } - return emit(result{Error: fmt.Sprintf("read dir: %v", err)}) - } - - removed := 0 - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasPrefix(name, conflictPrefix) { - continue - } - // Must also match the canonical conflict regex so we only delete real - // Syncthing-generated copies, not user files that happen to share the prefix. - matches := conflictPattern.FindStringSubmatch(name) - if matches == nil { - continue - } - // Defensive: matched stem must equal what we expected. - if matches[1] != stem { - continue - } - // Extension on the conflict copy must equal the original's extension - // (handles files where stem itself contains dots). - if matches[4] != ext { - continue - } - fullPath := filepath.Join(dir, name) - if err := os.Remove(fullPath); err != nil { - return emit(result{Removed: removed, Error: fmt.Sprintf("remove %s: %v", name, err)}) - } - removed++ - } - - return emit(result{Removed: removed}) + return `{"removed":0,"error":"vaultsync-conflict-recovery-unavailable"}` } // AutoResolveStateConflicts is retained for gomobile ABI compatibility but no diff --git a/go/bridge/conflicts_test.go b/go/bridge/conflicts_test.go index 9fb1cf8..a845c93 100644 --- a/go/bridge/conflicts_test.go +++ b/go/bridge/conflicts_test.go @@ -3,8 +3,10 @@ package bridge import ( "bytes" "encoding/json" + "fmt" "os" "path/filepath" + "reflect" "strings" "sync" "syscall" @@ -12,6 +14,240 @@ import ( "time" ) +const issue150ConflictRecoveryUnavailable = "vaultsync-conflict-recovery-unavailable" + +type issue150RecoveryEntry struct { + Mode os.FileMode + ModTime time.Time + Content []byte +} + +func issue150RecoverySnapshot(t *testing.T, root string) map[string]issue150RecoveryEntry { + t.Helper() + + snapshot := make(map[string]issue150RecoveryEntry) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + item := issue150RecoveryEntry{ + Mode: info.Mode(), + ModTime: info.ModTime(), + } + if info.Mode().IsRegular() { + item.Content, err = os.ReadFile(path) + if err != nil { + return err + } + } + snapshot[rel] = item + return nil + }) + if err != nil { + t.Fatalf("snapshot recovery fixture: %v", err) + } + return snapshot +} + +func TestIssue150ConflictRecoveryABIStubsAreStablePathFreeAndReadOnly(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + t.Cleanup(StopSyncthing) + + const folderID = "issue150-recovery-read-only" + folderPath := filepath.Join(configDir, folderID) + if errMsg := addFolderForTesting(folderID, "Issue 150 synthetic recovery", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + removeError := func(raw string) string { + var result struct { + Removed int `json:"removed"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Errorf("decode recovery stub response: %v (raw: %q)", err, raw) + return raw + } + if result.Removed != 0 { + t.Errorf("removed = %d, want zero", result.Removed) + } + return result.Error + } + + type recoveryCall struct { + name string + conflictInspectable bool + call func(conflictName, originalName string) string + } + calls := []recoveryCall{ + { + name: "ResolveConflict Keep This (#150)", + call: func(conflictName, _ string) string { + return ResolveConflict(folderID, conflictName, false) + }, + }, + { + name: "ResolveConflict Keep Other (#150)", + call: func(conflictName, _ string) string { + return ResolveConflict(folderID, conflictName, true) + }, + }, + { + name: "KeepBothConflict (#150)", + conflictInspectable: true, + call: func(conflictName, _ string) string { + return KeepBothConflict(folderID, conflictName) + }, + }, + { + name: "RemoveConflictFilesForOriginal Always Skip (#150)", + call: func(_, originalName string) string { + return removeError(RemoveConflictFilesForOriginal(folderID, originalName)) + }, + }, + } + + for index, testCase := range calls { + t.Run(testCase.name, func(t *testing.T) { + dirName := fmt.Sprintf("case-%d", index) + dir := filepath.Join(folderPath, dirName) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create recovery fixture: %v", err) + } + originalName := filepath.Join(dirName, "note.md") + conflictName := filepath.Join(dirName, "note.sync-conflict-20260829-120000-ABC1234.md") + fixtures := map[string][]byte{ + filepath.Join(folderPath, originalName): []byte("local version\n"), + filepath.Join(folderPath, conflictName): []byte("other version\n"), + filepath.Join(dir, ".stignore"): []byte("existing-rule\n"), + filepath.Join(dir, ".syncthing.vaultsync-resolve-existing"): []byte("existing temporary bytes\n"), + } + for path, content := range fixtures { + if err := os.WriteFile(path, content, 0o640); err != nil { + t.Fatalf("write recovery fixture: %v", err) + } + } + + before := issue150RecoverySnapshot(t, dir) + got := testCase.call(conflictName, originalName) + if got != issue150ConflictRecoveryUnavailable { + t.Errorf("error = %q, want fixed recovery-unavailable code", got) + } + for _, forbidden := range []string{folderID, folderPath, originalName, conflictName, "ABC1234"} { + if strings.Contains(got, forbidden) { + t.Errorf("fixed recovery error leaked input %q: %q", forbidden, got) + } + } + if testCase.conflictInspectable { + var conflicts []ConflictFile + raw := GetConflictFilesJSON(folderID) + if err := json.Unmarshal([]byte(raw), &conflicts); err != nil { + t.Fatalf("decode conflict inspection response: %v (raw: %q)", err, raw) + } + found := false + for _, conflict := range conflicts { + if conflict.ConflictPath == conflictName { + found = true + break + } + } + if !found { + t.Errorf("read-only Keep Both hid conflict %q from inspection", conflictName) + } + } + after := issue150RecoverySnapshot(t, dir) + if !reflect.DeepEqual(after, before) { + t.Errorf("recovery ABI mutated filesystem or temporary entries:\nbefore=%#v\nafter=%#v", before, after) + } + }) + } + + invalidCalls := []struct { + name string + call func() string + }{ + { + name: "ResolveConflict Keep This traversal (#150)", + call: func() string { + return ResolveConflict(folderID, filepath.Join("..", "outside.sync-conflict-20260829-120000-ABC1234.md"), false) + }, + }, + { + name: "ResolveConflict Keep This invalid filename (#150)", + call: func() string { + return ResolveConflict(folderID, "not-a-conflict.md", false) + }, + }, + { + name: "ResolveConflict Keep Other traversal (#150)", + call: func() string { + return ResolveConflict(folderID, filepath.Join("..", "outside.sync-conflict-20260829-120000-ABC1234.md"), true) + }, + }, + { + name: "ResolveConflict Keep Other invalid filename (#150)", + call: func() string { + return ResolveConflict(folderID, "not-a-conflict.md", true) + }, + }, + { + name: "KeepBothConflict traversal (#150)", + call: func() string { + return KeepBothConflict(folderID, filepath.Join("..", "outside.sync-conflict-20260829-120000-ABC1234.md")) + }, + }, + { + name: "KeepBothConflict invalid filename (#150)", + call: func() string { + return KeepBothConflict(folderID, "not-a-conflict.md") + }, + }, + { + name: "RemoveConflictFilesForOriginal traversal (#150)", + call: func() string { + return removeError(RemoveConflictFilesForOriginal(folderID, filepath.Join("..", "outside.md"))) + }, + }, + { + name: "RemoveConflictFilesForOriginal invalid root path (#150)", + call: func() string { + return removeError(RemoveConflictFilesForOriginal(folderID, ".")) + }, + }, + } + for _, testCase := range invalidCalls { + t.Run(testCase.name, func(t *testing.T) { + if got := testCase.call(); got != issue150ConflictRecoveryUnavailable { + t.Errorf("invalid-input error = %q, want context-independent fixed code", got) + } + }) + } + + StopSyncthing() + for _, got := range []string{ + ResolveConflict("redaction-probe-folder", "redaction-probe/path.sync-conflict-20260829-120000-ABC1234.md", false), + ResolveConflict("redaction-probe-folder", "redaction-probe/path.sync-conflict-20260829-120000-ABC1234.md", true), + KeepBothConflict("redaction-probe-folder", "redaction-probe/path.sync-conflict-20260829-120000-ABC1234.md"), + } { + if got != issue150ConflictRecoveryUnavailable { + t.Errorf("stopped-engine error = %q, want context-independent fixed code", got) + } + } + if got := removeError(RemoveConflictFilesForOriginal("redaction-probe-folder", "redaction-probe/path.md")); got != issue150ConflictRecoveryUnavailable { + t.Errorf("stopped-engine remove error = %q, want context-independent fixed code", got) + } +} + func TestGetConflictFilesJSON(t *testing.T) { configDir := testConfigDir(t) @@ -22,7 +258,7 @@ func TestGetConflictFilesJSON(t *testing.T) { // Add a folder. folderPath := filepath.Join(configDir, "conflicttest") - if errMsg := AddFolder("conflicttest", "Conflict Test", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("conflicttest", "Conflict Test", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -91,9 +327,9 @@ func TestGetConflictFilesJSON(t *testing.T) { t.Error("subdirectory conflict not found") } - // Nonexistent folder returns empty array. - if got := GetConflictFilesJSON("nonexistent"); got != "[]" { - t.Errorf("nonexistent folder = %q, want '[]'", got) + // An unavailable folder must not be confused with a verified empty scan. + if got := GetConflictFilesJSON("nonexistent"); got != conflictInspectionUnavailableError { + t.Errorf("nonexistent folder = %q, want fixed unavailable code", got) } } @@ -106,72 +342,46 @@ func TestReadFileContent(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "readtest") - if errMsg := AddFolder("readtest", "Read Test", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("readtest", "Read Test", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } content := "# Hello World\n\nThis is a test." os.WriteFile(filepath.Join(folderPath, "test.md"), []byte(content), 0o644) - got := ReadFileContent("readtest", "test.md") - if got != content { - t.Errorf("ReadFileContent = %q, want %q", got, content) - } - - // Nonexistent file returns error prefix. - if got := ReadFileContent("readtest", "nope.md"); !strings.HasPrefix(got, "error:") { - t.Errorf("nonexistent file = %q, want error: prefix", got) - } - - // Path traversal returns error prefix. - if got := ReadFileContent("readtest", "../../etc/passwd"); !strings.HasPrefix(got, "error:") { - t.Errorf("path traversal = %q, want error: prefix", got) - } - - // Nonexistent folder returns error prefix. - if got := ReadFileContent("nonexistent", "test.md"); !strings.HasPrefix(got, "error:") { - t.Errorf("nonexistent folder = %q, want error: prefix", got) + type result struct { + Content *string `json:"content"` + Error string `json:"error"` } -} - -func TestResolveConflictKeepOriginal(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) + decode := func(raw string) result { + t.Helper() + var got result + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("decode inspection result %q: %v", raw, err) + } + return got } - defer StopSyncthing() - folderPath := filepath.Join(configDir, "resolvetest") - if errMsg := AddFolder("resolvetest", "Resolve Test", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) + got := decode(ReadFileContent("readtest", "test.md")) + if got.Content == nil || *got.Content != content || got.Error != "" { + t.Errorf("ReadFileContent = %+v, want exact content", got) } - // Create original and conflict. - original := filepath.Join(folderPath, "doc.md") - os.WriteFile(original, []byte("original"), 0o644) - - conflictName := "doc.sync-conflict-20260406-100000-DEF5678.md" - os.WriteFile(filepath.Join(folderPath, conflictName), []byte("conflict version"), 0o644) - - // Resolve: keep original (delete conflict). - if errMsg := ResolveConflict("resolvetest", conflictName, false); errMsg != "" { - t.Fatalf("ResolveConflict(keepConflict=false) failed: %s", errMsg) + // Inspection failures return only the stable path-free code. + if got := decode(ReadFileContent("readtest", "nope.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + t.Errorf("nonexistent file = %+v, want unavailable", got) } - // Original should be unchanged. - data, _ := os.ReadFile(original) - if string(data) != "original" { - t.Errorf("original content = %q, want %q", string(data), "original") + if got := decode(ReadFileContent("readtest", "../../etc/passwd")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + t.Errorf("path traversal = %+v, want unavailable", got) } - // Conflict file should be gone. - if _, err := os.Stat(filepath.Join(folderPath, conflictName)); !os.IsNotExist(err) { - t.Error("conflict file should have been deleted") + if got := decode(ReadFileContent("nonexistent", "test.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + t.Errorf("nonexistent folder = %+v, want unavailable", got) } } -func TestResolveConflictKeepConflict(t *testing.T) { +func TestIssue150ConflictInspectionFailureIsNotAnEmptySuccess(t *testing.T) { configDir := testConfigDir(t) if errMsg := StartSyncthing(configDir); errMsg != "" { @@ -179,36 +389,27 @@ func TestResolveConflictKeepConflict(t *testing.T) { } defer StopSyncthing() - folderPath := filepath.Join(configDir, "resolvetest2") - if errMsg := AddFolder("resolvetest2", "Resolve Test 2", folderPath); errMsg != "" { + folderPath := filepath.Join(configDir, "issue150-inspection-unavailable") + if errMsg := addFolderForTesting("issue150-inspection-unavailable", "Inspection", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } - - // Create original and conflict. - original := filepath.Join(folderPath, "doc.md") - os.WriteFile(original, []byte("original"), 0o644) - - conflictName := "doc.sync-conflict-20260406-100000-DEF5678.md" - os.WriteFile(filepath.Join(folderPath, conflictName), []byte("conflict version"), 0o644) - - // Resolve: keep conflict (replace original). - if errMsg := ResolveConflict("resolvetest2", conflictName, true); errMsg != "" { - t.Fatalf("ResolveConflict(keepConflict=true) failed: %s", errMsg) + if err := os.RemoveAll(folderPath); err != nil { + t.Fatalf("remove isolated fixture folder: %v", err) } - // Original should now have conflict content. - data, _ := os.ReadFile(original) - if string(data) != "conflict version" { - t.Errorf("original content = %q, want %q", string(data), "conflict version") + if got := GetConflictFilesJSON("issue150-inspection-unavailable"); got != conflictInspectionUnavailableError { + t.Fatalf("missing-folder inspection = %q, want fixed unavailable code", got) } - - // Conflict file should be gone. - if _, err := os.Stat(filepath.Join(folderPath, conflictName)); !os.IsNotExist(err) { - t.Error("conflict file should have been deleted") + if got := GetConflictFilesJSON("issue150-unknown-folder"); got != conflictInspectionUnavailableError { + t.Fatalf("unknown-folder inspection = %q, want fixed unavailable code", got) + } + StopSyncthing() + if got := GetConflictFilesJSON("issue150-inspection-unavailable"); got != conflictInspectionUnavailableError { + t.Fatalf("stopped-engine inspection = %q, want fixed unavailable code", got) } } -func TestIssue143ResolveConflictPreservesExistingTemporaryFile(t *testing.T) { +func TestIssue150ConflictInspectionDistinguishesEmptyContentAndUnavailableWithoutDetails(t *testing.T) { configDir := testConfigDir(t) if errMsg := StartSyncthing(configDir); errMsg != "" { @@ -216,133 +417,53 @@ func TestIssue143ResolveConflictPreservesExistingTemporaryFile(t *testing.T) { } defer StopSyncthing() - const folderID = "issue143temp" - folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 143 Temp Collision", folderPath); errMsg != "" { + folderPath := filepath.Join(configDir, "issue150-content-inspection") + if errMsg := addFolderForTesting("issue150-content-inspection", "Inspection", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } - const conflictName = "doc.sync-conflict-20260406-100000-DEF5678.md" - originalPath := filepath.Join(folderPath, "doc.md") - conflictPath := filepath.Join(folderPath, conflictName) - tempPath := originalPath + ".vaultsync-tmp" - unrelatedPath := filepath.Join(folderPath, "unrelated-sentinel.md") - - originalSentinel := []byte("issue-143-original-sentinel") - conflictSentinel := []byte("issue-143-conflict-sentinel") - tempSentinel := []byte("issue-143-existing-temp-sentinel") - unrelatedSentinel := []byte("issue-143-unrelated-sentinel") - fixtures := []struct { - name string - path string - data []byte - }{ - {name: "original", path: originalPath, data: originalSentinel}, - {name: "conflict", path: conflictPath, data: conflictSentinel}, - {name: "pre-existing temp", path: tempPath, data: tempSentinel}, - {name: "unrelated", path: unrelatedPath, data: unrelatedSentinel}, - } - for _, fixture := range fixtures { - if err := os.WriteFile(fixture.path, fixture.data, 0o644); err != nil { - t.Fatalf("write %s fixture: %v", fixture.name, err) - } - got, err := os.ReadFile(fixture.path) - if err != nil { - t.Fatalf("read back %s fixture: %v", fixture.name, err) - } - if !bytes.Equal(got, fixture.data) { - t.Fatalf("%s fixture bytes = %q, want %q", fixture.name, got, fixture.data) - } - } - - if errMsg := ResolveConflict(folderID, conflictName, true); errMsg != "" { - t.Fatalf("ResolveConflict(keepConflict=true) failed: %s", errMsg) - } - - tempAfter, err := os.ReadFile(tempPath) - if err != nil { - t.Fatalf("pre-existing temp file was not preserved: %v", err) - } - if !bytes.Equal(tempAfter, tempSentinel) { - t.Errorf("pre-existing temp bytes = %q, want %q", tempAfter, tempSentinel) - } - - originalAfter, err := os.ReadFile(originalPath) - if err != nil { - t.Fatalf("read resolved original: %v", err) - } - if !bytes.Equal(originalAfter, conflictSentinel) { - t.Errorf("resolved original bytes = %q, want conflict bytes %q", originalAfter, conflictSentinel) - } - if _, err := os.Stat(conflictPath); !os.IsNotExist(err) { - t.Errorf("resolved conflict path still exists or stat failed: %v", err) - } - unrelatedAfter, err := os.ReadFile(unrelatedPath) - if err != nil { - t.Fatalf("read unrelated file: %v", err) - } - if !bytes.Equal(unrelatedAfter, unrelatedSentinel) { - t.Errorf("unrelated bytes = %q, want %q", unrelatedAfter, unrelatedSentinel) - } - ownedTemps, err := filepath.Glob(filepath.Join(folderPath, ".syncthing.vaultsync-resolve-*")) - if err != nil { - t.Fatalf("glob VaultSync temporary files: %v", err) - } - if len(ownedTemps) != 0 { - t.Errorf("successful resolution left VaultSync temporary files: %v", ownedTemps) + type result struct { + Content *string `json:"content"` + Error string `json:"error"` } -} - -func TestIssue143ResolveConflictRejectsPathTraversal(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) + decode := func(raw string) result { + t.Helper() + var got result + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("decode inspection result %q: %v", raw, err) + } + return got } - defer StopSyncthing() - const folderID = "issue143traversal" - folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 143 Traversal", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) + fixtures := map[string]string{ + "empty.md": "", + "error-prefix.md": "error:this is legitimate note content", } - - const conflictName = "outside.sync-conflict-20260406-100000-DEF5678.md" - originalPath := filepath.Join(configDir, "outside.md") - conflictPath := filepath.Join(configDir, conflictName) - legacyPath := originalPath + ".vaultsync-tmp" - unrelatedPath := filepath.Join(configDir, "outside-unrelated-sentinel.md") - originalBytes := []byte("issue-143-traversal-original") - conflictBytes := []byte("issue-143-traversal-conflict") - legacyBytes := []byte("issue-143-traversal-legacy-temp") - unrelatedBytes := []byte("issue-143-traversal-unrelated") - - fixtures := []struct { - name string - path string - data []byte - }{ - {name: "outside original", path: originalPath, data: originalBytes}, - {name: "outside conflict", path: conflictPath, data: conflictBytes}, - {name: "outside legacy temp", path: legacyPath, data: legacyBytes}, - {name: "outside unrelated", path: unrelatedPath, data: unrelatedBytes}, - } - for _, fixture := range fixtures { - if err := os.WriteFile(fixture.path, fixture.data, 0o600); err != nil { - t.Fatalf("write %s: %v", fixture.name, err) + for name, content := range fixtures { + if err := os.WriteFile(filepath.Join(folderPath, name), []byte(content), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + got := decode(ReadFileContent("issue150-content-inspection", name)) + if got.Content == nil || *got.Content != content || got.Error != "" { + t.Fatalf("successful inspection for %q = %+v, want exact content", name, got) } - issue143AssertFileBytes(t, fixture.path, fixture.data) - } - - errMsg := ResolveConflict(folderID, filepath.Join("..", conflictName), true) - if errMsg != "invalid path: outside folder root" { - t.Fatalf("ResolveConflict traversal error = %q, want invalid path error", errMsg) } - for _, fixture := range fixtures { - issue143AssertFileBytes(t, fixture.path, fixture.data) + for _, raw := range []string{ + ReadFileContent("issue150-content-inspection", "missing-redaction-probe.md"), + ReadFileContent("issue150-content-inspection", "../outside-redaction-probe.md"), + ReadFileContent("issue150-unknown-folder", "missing-redaction-probe.md"), + } { + got := decode(raw) + if got.Content != nil || got.Error != conflictInspectionUnavailableError { + t.Fatalf("failed inspection = %+v, want fixed unavailable result", got) + } + for _, sensitiveDetail := range []string{"missing-redaction-probe", "outside-redaction-probe", folderPath} { + if strings.Contains(raw, sensitiveDetail) { + t.Fatalf("failed inspection leaked private detail in %q", raw) + } + } } - issue143AssertNoOperationTemps(t, configDir) } func TestIssue143ResolveConflictPreservesModeAndSupportsMissingOriginal(t *testing.T) { @@ -843,214 +964,6 @@ func issue143OperationTemps(t *testing.T, dir string) []string { return temps } -func TestResolveConflictErrors(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) - } - defer StopSyncthing() - - folderPath := filepath.Join(configDir, "resolveerr") - if errMsg := AddFolder("resolveerr", "Resolve Err", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } - - // Nonexistent folder. - if errMsg := ResolveConflict("nonexistent", "file.md", false); errMsg != "folder not found" { - t.Errorf("nonexistent folder = %q, want 'folder not found'", errMsg) - } - - // Nonexistent conflict file. - if errMsg := ResolveConflict("resolveerr", "nope.sync-conflict-20260406-100000-ABC1234.md", false); errMsg != "conflict file not found" { - t.Errorf("nonexistent file = %q, want 'conflict file not found'", errMsg) - } - - // Invalid conflict filename with keepConflict=true. - normalFile := filepath.Join(folderPath, "normal.md") - os.WriteFile(normalFile, []byte("normal"), 0o644) - if errMsg := ResolveConflict("resolveerr", "normal.md", true); errMsg != "invalid conflict filename" { - t.Errorf("invalid filename = %q, want 'invalid conflict filename'", errMsg) - } -} - -func TestKeepBothConflict(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) - } - defer StopSyncthing() - - folderPath := filepath.Join(configDir, "keepbothtest") - if errMsg := AddFolder("keepbothtest", "Keep Both Test", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } - - // Create original and conflict. - original := filepath.Join(folderPath, "doc.md") - os.WriteFile(original, []byte("original"), 0o644) - - conflictName := "doc.sync-conflict-20260406-100000-DEF5678.md" - os.WriteFile(filepath.Join(folderPath, conflictName), []byte("conflict version"), 0o644) - - // Keep both: rename conflict to non-conflict name. - if errMsg := KeepBothConflict("keepbothtest", conflictName); errMsg != "" { - t.Fatalf("KeepBothConflict failed: %s", errMsg) - } - - // Original should still exist unchanged. - data, _ := os.ReadFile(original) - if string(data) != "original" { - t.Errorf("original content = %q, want %q", string(data), "original") - } - - // Conflict file should be gone. - if _, err := os.Stat(filepath.Join(folderPath, conflictName)); !os.IsNotExist(err) { - t.Error("conflict file should have been renamed") - } - - // Renamed file should exist with new name. - renamedPath := filepath.Join(folderPath, "doc.conflict-DEF5678.md") - data, err := os.ReadFile(renamedPath) - if err != nil { - t.Fatalf("renamed file not found: %v", err) - } - if string(data) != "conflict version" { - t.Errorf("renamed content = %q, want %q", string(data), "conflict version") - } - - // Should no longer appear in conflict scan. - got := GetConflictFilesJSON("keepbothtest") - var conflicts []ConflictFile - if err := json.Unmarshal([]byte(got), &conflicts); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(conflicts) != 0 { - t.Errorf("expected 0 conflicts after keep-both, got %d", len(conflicts)) - } -} - -func TestIssue144KeepBothPreservesExistingTarget(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) - } - defer StopSyncthing() - - const folderID = "issue144keepbothcollision" - folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 144 Keep Both Collision", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } - - conflictName := "doc.sync-conflict-20260406-100000-DEF5678.md" - originalPath := filepath.Join(folderPath, "doc.md") - conflictPath := filepath.Join(folderPath, conflictName) - targetPath := filepath.Join(folderPath, "doc.conflict-DEF5678.md") - originalBytes := []byte("issue-144-original-sentinel") - conflictBytes := []byte("issue-144-conflict-sentinel") - targetBytes := []byte("issue-144-existing-target-sentinel") - - if bytes.Equal(originalBytes, conflictBytes) || bytes.Equal(originalBytes, targetBytes) || bytes.Equal(conflictBytes, targetBytes) { - t.Fatal("issue #144 fixture bytes must be pairwise distinct") - } - - fixtureFiles := []struct { - name string - path string - data []byte - }{ - {name: "original", path: originalPath, data: originalBytes}, - {name: "conflict", path: conflictPath, data: conflictBytes}, - {name: "existing Keep Both target", path: targetPath, data: targetBytes}, - } - for _, file := range fixtureFiles { - if err := os.WriteFile(file.path, file.data, 0o600); err != nil { - t.Fatalf("write %s fixture: %v", file.name, err) - } - } - - before := make(map[string][]byte, len(fixtureFiles)) - for _, file := range fixtureFiles { - got, err := os.ReadFile(file.path) - if err != nil { - t.Fatalf("read back %s fixture: %v", file.name, err) - } - if !bytes.Equal(got, file.data) { - t.Fatalf("%s fixture bytes = %q, want %q", file.name, got, file.data) - } - before[file.path] = append([]byte(nil), got...) - } - - errMsg := KeepBothConflict(folderID, conflictName) - - targetAfter, err := os.ReadFile(targetPath) - if err != nil { - t.Fatalf("read existing Keep Both target after KeepBothConflict: %v", err) - } - if !bytes.Equal(targetAfter, before[targetPath]) { - t.Errorf( - "existing Keep Both target bytes after KeepBothConflict(error=%q) = %q, want preserved bytes %q", - errMsg, - targetAfter, - before[targetPath], - ) - } - - originalAfter, err := os.ReadFile(originalPath) - if err != nil { - t.Fatalf("read original after KeepBothConflict: %v", err) - } - if !bytes.Equal(originalAfter, before[originalPath]) { - t.Errorf("original bytes after KeepBothConflict = %q, want %q", originalAfter, before[originalPath]) - } - - if errMsg != "" { - if errMsg != keepBothTargetExistsError { - t.Errorf("KeepBothConflict collision error = %q, want %q", errMsg, keepBothTargetExistsError) - } - conflictAfter, err := os.ReadFile(conflictPath) - if err != nil { - t.Fatalf("read conflict after non-destructive error %q: %v", errMsg, err) - } - if !bytes.Equal(conflictAfter, before[conflictPath]) { - t.Errorf("conflict bytes after error %q = %q, want %q", errMsg, conflictAfter, before[conflictPath]) - } - return - } - - if _, err := os.Stat(conflictPath); !os.IsNotExist(err) { - t.Errorf("successful KeepBothConflict left the sync-conflict source in place: %v", err) - } - - entries, err := os.ReadDir(folderPath) - if err != nil { - t.Fatalf("read folder after KeepBothConflict: %v", err) - } - preservedConflictPath := "" - for _, entry := range entries { - if entry.IsDir() { - continue - } - candidatePath := filepath.Join(folderPath, entry.Name()) - candidateBytes, err := os.ReadFile(candidatePath) - if err != nil { - t.Fatalf("read %q while locating preserved conflict bytes: %v", entry.Name(), err) - } - if bytes.Equal(candidateBytes, before[conflictPath]) { - preservedConflictPath = candidatePath - break - } - } - if preservedConflictPath == "" { - t.Error("successful KeepBothConflict lost the conflict bytes") - } else if conflictPattern.MatchString(filepath.Base(preservedConflictPath)) { - t.Errorf("successful KeepBothConflict left conflict bytes under sync-conflict name %q", filepath.Base(preservedConflictPath)) - } -} - func TestIssue144KeepBothSuccessPreservesAllContents(t *testing.T) { dir := t.TempDir() conflictName := "doc.sync-conflict-20260406-100000-DEF5678.md" @@ -1076,36 +989,6 @@ func TestIssue144KeepBothSuccessPreservesAllContents(t *testing.T) { issue144AssertFileBytes(t, unrelatedPath, unrelatedBytes) } -func TestIssue144KeepBothSameShortIDCollisionPreservesAllContents(t *testing.T) { - const folderID = "issue144sameid" - folderPath := issue144StartFolder(t, folderID) - originalPath := filepath.Join(folderPath, "doc.md") - firstName := "doc.sync-conflict-20260406-100000-DEF5678.md" - secondName := "doc.sync-conflict-20260406-100001-DEF5678.md" - firstPath := filepath.Join(folderPath, firstName) - secondPath := filepath.Join(folderPath, secondName) - targetPath := filepath.Join(folderPath, "doc.conflict-DEF5678.md") - originalBytes := []byte("issue-144-same-id-original") - firstBytes := []byte("issue-144-same-id-first") - secondBytes := []byte("issue-144-same-id-second") - - issue144WriteAndReadBack(t, originalPath, originalBytes) - issue144WriteAndReadBack(t, firstPath, firstBytes) - issue144WriteAndReadBack(t, secondPath, secondBytes) - - if errMsg := KeepBothConflict(folderID, firstName); errMsg != "" { - t.Fatalf("first KeepBothConflict failed: %s", errMsg) - } - if errMsg := KeepBothConflict(folderID, secondName); errMsg != keepBothTargetExistsError { - t.Fatalf("second KeepBothConflict error = %q, want %q", errMsg, keepBothTargetExistsError) - } - - issue144AssertFileBytes(t, originalPath, originalBytes) - issue144AssertPathMissing(t, firstPath) - issue144AssertFileBytes(t, targetPath, firstBytes) - issue144AssertFileBytes(t, secondPath, secondBytes) -} - func TestIssue144KeepBothParallelCollisionPreservesAllContents(t *testing.T) { folderPath := t.TempDir() originalPath := filepath.Join(folderPath, "doc.md") @@ -1317,30 +1200,6 @@ func TestIssue144KeepBothOperationFailuresPreserveAllContents(t *testing.T) { } } -func TestIssue144KeepBothRejectsPathTraversal(t *testing.T) { - const folderID = "issue144traversal" - folderPath := issue144StartFolder(t, folderID) - outsideDir := filepath.Dir(folderPath) - outsideConflictName := "outside.sync-conflict-20260406-100000-DEF5678.md" - outsideConflictPath := filepath.Join(outsideDir, outsideConflictName) - outsideTargetPath := filepath.Join(outsideDir, "outside.conflict-DEF5678.md") - unrelatedPath := filepath.Join(outsideDir, "issue-144-traversal-unrelated.md") - conflictBytes := []byte("issue-144-traversal-conflict") - targetBytes := []byte("issue-144-traversal-target") - unrelatedBytes := []byte("issue-144-traversal-unrelated") - issue144WriteAndReadBack(t, outsideConflictPath, conflictBytes) - issue144WriteAndReadBack(t, outsideTargetPath, targetBytes) - issue144WriteAndReadBack(t, unrelatedPath, unrelatedBytes) - - errMsg := KeepBothConflict(folderID, filepath.Join("..", outsideConflictName)) - if errMsg != "invalid path: outside folder root" { - t.Fatalf("path traversal error = %q, want %q", errMsg, "invalid path: outside folder root") - } - issue144AssertFileBytes(t, outsideConflictPath, conflictBytes) - issue144AssertFileBytes(t, outsideTargetPath, targetBytes) - issue144AssertFileBytes(t, unrelatedPath, unrelatedBytes) -} - func TestIssue144KeepBothPreservesUnexpectedTargetNodes(t *testing.T) { t.Run("directory", func(t *testing.T) { dir := t.TempDir() @@ -1402,7 +1261,7 @@ func issue144StartFolder(t *testing.T, folderID string) string { t.Cleanup(func() { StopSyncthing() }) folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 144 Keep Both", folderPath); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Issue 144 Keep Both", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } return folderPath @@ -1484,175 +1343,6 @@ func TestRenameDevice(t *testing.T) { } } -func TestRemoveConflictFilesForOriginal(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) - } - defer StopSyncthing() - - folderPath := filepath.Join(configDir, "skipfamily") - if errMsg := AddFolder("skipfamily", "Skip Family", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } - - // Root-level original + two conflict copies (different timestamps/devices). - if err := os.WriteFile(filepath.Join(folderPath, "notes.md"), []byte("original"), 0o644); err != nil { - t.Fatalf("write notes.md: %v", err) - } - if err := os.WriteFile(filepath.Join(folderPath, "notes.sync-conflict-20260520-120000-AAA1111.md"), []byte("c1"), 0o644); err != nil { - t.Fatalf("write notes conflict c1: %v", err) - } - if err := os.WriteFile(filepath.Join(folderPath, "notes.sync-conflict-20260521-130000-BBB2222.md"), []byte("c2"), 0o644); err != nil { - t.Fatalf("write notes conflict c2: %v", err) - } - - // Unrelated file that must not be touched. - if err := os.WriteFile(filepath.Join(folderPath, "other.md"), []byte("other"), 0o644); err != nil { - t.Fatalf("write other.md: %v", err) - } - if err := os.WriteFile(filepath.Join(folderPath, "other.sync-conflict-20260520-120000-CCC3333.md"), []byte("o1"), 0o644); err != nil { - t.Fatalf("write other conflict: %v", err) - } - - // Nested original + nested conflict. - subDir := filepath.Join(folderPath, "Personal") - if err := os.MkdirAll(subDir, 0o755); err != nil { - t.Fatalf("mkdir subDir: %v", err) - } - if err := os.WriteFile(filepath.Join(subDir, "diary.md"), []byte("d"), 0o644); err != nil { - t.Fatalf("write diary.md: %v", err) - } - if err := os.WriteFile(filepath.Join(subDir, "diary.sync-conflict-20260520-120000-DDD4444.md"), []byte("d1"), 0o644); err != nil { - t.Fatalf("write diary conflict: %v", err) - } - - // Remove conflict copies for "notes.md" only. - got := RemoveConflictFilesForOriginal("skipfamily", "notes.md") - var result struct { - Removed int `json:"removed"` - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(got), &result); err != nil { - t.Fatalf("unmarshal: %v (raw: %s)", err, got) - } - if result.Error != "" { - t.Fatalf("unexpected error: %s", result.Error) - } - if result.Removed != 2 { - t.Errorf("removed = %d, want 2", result.Removed) - } - - // Original "notes.md" must survive. - if _, err := os.Stat(filepath.Join(folderPath, "notes.md")); err != nil { - t.Errorf("notes.md should still exist: %v", err) - } - - // Both notes conflict copies must be gone. - for _, name := range []string{ - "notes.sync-conflict-20260520-120000-AAA1111.md", - "notes.sync-conflict-20260521-130000-BBB2222.md", - } { - if _, err := os.Stat(filepath.Join(folderPath, name)); !os.IsNotExist(err) { - t.Errorf("%s should have been deleted", name) - } - } - - // Unrelated "other.*" files must survive. - if _, err := os.Stat(filepath.Join(folderPath, "other.md")); err != nil { - t.Errorf("other.md should still exist: %v", err) - } - if _, err := os.Stat(filepath.Join(folderPath, "other.sync-conflict-20260520-120000-CCC3333.md")); err != nil { - t.Errorf("other.sync-conflict-* should still exist: %v", err) - } - - // Nested originals and their conflicts in another directory must survive - // when we ask for the root file only. - if _, err := os.Stat(filepath.Join(subDir, "diary.sync-conflict-20260520-120000-DDD4444.md")); err != nil { - t.Errorf("nested conflict should still exist: %v", err) - } - - // Now ask for nested "Personal/diary.md" and verify only the nested copy goes. - got = RemoveConflictFilesForOriginal("skipfamily", filepath.Join("Personal", "diary.md")) - if err := json.Unmarshal([]byte(got), &result); err != nil { - t.Fatalf("unmarshal nested: %v (raw: %s)", err, got) - } - if result.Removed != 1 || result.Error != "" { - t.Errorf("nested call result = %+v, want removed=1 error=\"\"", result) - } - - // Dotted-stem regression: archive.tar.gz must match its own conflict copy - // but not a sibling that happens to share the inner stem. - if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.gz"), []byte("a"), 0o644); err != nil { - t.Fatalf("write archive.tar.gz: %v", err) - } - if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-EEE5555.gz"), []byte("ac"), 0o644); err != nil { - t.Fatalf("write archive conflict copy: %v", err) - } - // Same inner stem but different extension — must NOT match. - if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-FFF6666.md"), []byte("decoy"), 0o644); err != nil { - t.Fatalf("write decoy: %v", err) - } - - got = RemoveConflictFilesForOriginal("skipfamily", "archive.tar.gz") - if err := json.Unmarshal([]byte(got), &result); err != nil { - t.Fatalf("unmarshal dotted: %v (raw: %s)", err, got) - } - if result.Removed != 1 || result.Error != "" { - t.Errorf("dotted-stem call result = %+v, want removed=1 error=\"\"", result) - } - if _, err := os.Stat(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-EEE5555.gz")); !os.IsNotExist(err) { - t.Error("archive.tar.gz conflict copy should have been deleted") - } - if _, err := os.Stat(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-FFF6666.md")); err != nil { - t.Errorf("decoy with different extension should still exist: %v", err) - } - - // Idempotency: running again returns removed=0, no error. - got = RemoveConflictFilesForOriginal("skipfamily", "notes.md") - if err := json.Unmarshal([]byte(got), &result); err != nil { - t.Fatalf("unmarshal idempotent: %v (raw: %s)", err, got) - } - if result.Removed != 0 || result.Error != "" { - t.Errorf("idempotent call = %+v, want removed=0 error=\"\"", result) - } -} - -func TestRemoveConflictFilesForOriginalErrors(t *testing.T) { - configDir := testConfigDir(t) - - if errMsg := StartSyncthing(configDir); errMsg != "" { - t.Fatalf("StartSyncthing() failed: %s", errMsg) - } - defer StopSyncthing() - - folderPath := filepath.Join(configDir, "skipfamilyerr") - if errMsg := AddFolder("skipfamilyerr", "Skip Family Err", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } - - // Unknown folder. - got := RemoveConflictFilesForOriginal("nonexistent", "x.md") - if !strings.Contains(got, `"error":"folder not found"`) { - t.Errorf("unknown folder result = %q, want error 'folder not found'", got) - } - - // Path traversal. - got = RemoveConflictFilesForOriginal("skipfamilyerr", "../../etc/passwd") - if !strings.Contains(got, `"error":"invalid path: outside folder root"`) { - t.Errorf("traversal result = %q, want invalid-path error", got) - } - - // Empty / root-equivalent paths must be rejected (would otherwise scan outside folder root). - for _, rp := range []string{"", ".", "/"} { - got := RemoveConflictFilesForOriginal("skipfamilyerr", rp) - if !strings.Contains(got, `"error":"invalid path: outside folder root"`) { - t.Errorf("root path %q result = %q, want invalid-path error", rp, got) - } - } -} - func TestIssue145AutoResolveStateConflictsPreservesLegacyScenarios(t *testing.T) { configDir := testConfigDir(t) @@ -1663,7 +1353,7 @@ func TestIssue145AutoResolveStateConflictsPreservesLegacyScenarios(t *testing.T) const folderID = "issue145-legacy" folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 145 Legacy Scenarios", folderPath); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Issue 145 Legacy Scenarios", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -1910,7 +1600,7 @@ func TestIssue145AutoResolveStateConflictsPreservesAllFiles(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, folderID) - if errMsg := AddFolder(folderID, "Issue 145 Preserve", folderPath); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Issue 145 Preserve", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } diff --git a/go/bridge/devices.go b/go/bridge/devices.go index ebf44f2..341801e 100644 --- a/go/bridge/devices.go +++ b/go/bridge/devices.go @@ -53,7 +53,7 @@ func AddDevice(deviceID string, name string) string { cfg.Devices = append(cfg.Devices, newDevice) }) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() @@ -85,7 +85,7 @@ func RemoveDevice(deviceID string) string { cfg.Devices = devices }) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() diff --git a/go/bridge/events_test.go b/go/bridge/events_test.go index 03cb3d2..56c4ac7 100644 --- a/go/bridge/events_test.go +++ b/go/bridge/events_test.go @@ -1,6 +1,8 @@ package bridge import ( + "fmt" + "strings" "testing" "time" @@ -76,6 +78,96 @@ func TestBridgeEventDataFolderErrors(t *testing.T) { } } +func TestIssue150Issue167BridgeEventDataConflictRetentionSafetyIsPathFree(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + secretFolder := "vault-safety" + ev := events.Event{ + Type: events.FolderErrors, + Data: map[string]interface{}{ + "folder": secretFolder, + "errors": []map[string]interface{}{ + {"path": "other.md", "error": "permission denied"}, + {"path": secretPath, "error": conflictRetentionSafetyMarker}, + }, + }, + Time: time.Now(), + } + + data := bridgeEventData(ev) + if got := data["reason"]; got != conflictRetentionSafetyErrorReason { + t.Fatalf("reason = %v, want %s", got, conflictRetentionSafetyErrorReason) + } + if got := data["message"]; got != conflictRetentionSafetyErrorMessage { + t.Fatalf("message = %v, want fixed safety message", got) + } + if _, ok := data["path"]; ok { + t.Fatalf("safety event leaked path: %+v", data) + } + if _, ok := data["folder"]; ok { + t.Fatalf("safety event leaked folder: %+v", data) + } + if strings.Contains(fmt.Sprint(data), secretPath) || strings.Contains(fmt.Sprint(data), secretFolder) || strings.Contains(fmt.Sprint(data), conflictRetentionSafetyMarker) { + t.Fatalf("safety event leaked raw detail: %+v", data) + } +} + +func TestIssue150Issue167BridgeItemFinishedSafetyOmitsItemPath(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + secretFolder := "vault-safety" + ev := events.Event{ + Type: events.ItemFinished, + Data: map[string]interface{}{ + "folder": secretFolder, + "item": secretPath, + "type": "file", + "action": "update", + "error": conflictRetentionSafetyMarker, + }, + Time: time.Now(), + } + + data := bridgeEventData(ev) + if got := data["reason"]; got != conflictRetentionSafetyErrorReason { + t.Fatalf("reason = %v, want %s", got, conflictRetentionSafetyErrorReason) + } + if _, ok := data["item"]; ok { + t.Fatalf("safety item event leaked item path: %+v", data) + } + if _, ok := data["folder"]; ok { + t.Fatalf("safety item event leaked folder: %+v", data) + } + if strings.Contains(fmt.Sprint(data), secretPath) || strings.Contains(fmt.Sprint(data), secretFolder) || strings.Contains(fmt.Sprint(data), conflictRetentionSafetyMarker) { + t.Fatalf("safety item event leaked raw detail: %+v", data) + } +} + +func TestIssue150BridgeStateChangedSafetyCannotReportSuccessOrIdentifiers(t *testing.T) { + secretFolder := "redaction-probe-folder" + ev := events.Event{ + Type: events.StateChanged, + Data: map[string]interface{}{ + "folder": secretFolder, + "from": "syncing", + "to": "idle", + "error": conflictRetentionSafetyMarker, + }, + Time: time.Now(), + } + + data := bridgeEventData(ev) + if got := data["reason"]; got != conflictRetentionSafetyErrorReason { + t.Fatalf("reason = %v, want %s", got, conflictRetentionSafetyErrorReason) + } + for _, key := range []string{"folder", "from", "to", "item", "path", "id", "deviceName"} { + if _, ok := data[key]; ok { + t.Fatalf("safety state event retained %q: %+v", key, data) + } + } + if strings.Contains(fmt.Sprint(data), secretFolder) || strings.Contains(fmt.Sprint(data), conflictRetentionSafetyMarker) { + t.Fatalf("safety state event leaked raw detail: %+v", data) + } +} + func TestBridgeEventDataItemFinishedSkipsEmptyError(t *testing.T) { ev := events.Event{ Type: events.ItemFinished, diff --git a/go/bridge/folders.go b/go/bridge/folders.go index 713bd82..dd9176a 100644 --- a/go/bridge/folders.go +++ b/go/bridge/folders.go @@ -4,6 +4,7 @@ package bridge import ( "crypto/sha256" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -28,10 +29,20 @@ type FolderInfo struct { DeviceIDs []string `json:"deviceIDs"` } -// AddFolder adds a new folder with SendReceive type. -// The local device is automatically included in the share list. -// Returns empty string on success, error message on failure. +// AddFolder retains the pre-2.0.2 ABI but does not create a folder. It returns +// the stable, path-free receive-side safety error without inspecting its +// arguments or mutating filesystem or configuration state (#150). func AddFolder(id, label, path string) string { + // VaultSync 2.0.2 never creates a receive-capable folder through this ABI. + // Keep the signature stable, but stop before path validation, filesystem + // creation, config mutation, or runner startup (#150). + return conflictRetentionSafetyMarker +} + +// addFolderForTesting retains the validation core for focused bridge tests. +// Its fixture is send-only so tests cannot bypass the receive-side hard floor. +// Production code must use the exported inspection-only ABI above. +func addFolderForTesting(id, label, path string) string { mu.Lock() defer mu.Unlock() @@ -67,7 +78,7 @@ func AddFolder(id, label, path string) string { ID: id, Label: label, Path: path, - Type: config.FolderTypeSendReceive, + Type: config.FolderTypeSendOnly, RescanIntervalS: defaultRescanIntervalS, FSWatcherEnabled: true, FSWatcherDelayS: 10, @@ -102,11 +113,12 @@ func RemoveFolder(id string) string { // Check if folder exists. folders := stCfg.Folders() - if _, exists := folders[id]; !exists { + folder, exists := folders[id] + if !exists { return "folder not found" } - waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + modify := func(cfg *config.Configuration) { filtered := make([]config.FolderConfiguration, 0, len(cfg.Folders)) for _, f := range cfg.Folders { if f.ID != id { @@ -114,9 +126,14 @@ func RemoveFolder(id string) string { } } cfg.Folders = filtered - }) + } + waiter, err := modifyFolderConfiguration( + folder, + config.NewVaultSyncRemoveFolderCapability(id), + modify, + ) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() @@ -151,7 +168,10 @@ func SetFolderPath(folderID, newPath string) string { folders := stCfg.Folders() folder, exists := folders[folderID] if !exists { - return "folder not found" + return conflictRetentionSafetyMarker + } + if receiveSideReadOnlyForFolderType(folder.Type) { + return conflictRetentionSafetyMarker } // No-op when the path is effectively unchanged — avoids a needless folder @@ -200,6 +220,10 @@ func SetFolderPath(folderID, newPath string) string { return "" } +func receiveSideReadOnlyForFolderType(folderType config.FolderType) bool { + return folderType != config.FolderTypeSendOnly +} + // verifyFolderMarker returns "" if newPath holds the Syncthing folder marker for // this folder, or a user-facing error string if it does not. For the default // `.stfolder` marker it checks the folder-ID-specific fingerprint file @@ -257,16 +281,21 @@ func SetFolderPaused(folderID string, paused bool) string { return "" } - waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + modify := func(cfg *config.Configuration) { for i := range cfg.Folders { if cfg.Folders[i].ID == folderID { cfg.Folders[i].Paused = paused break } } - }) + } + waiter, err := modifyFolderConfiguration( + folder, + config.NewVaultSyncSetFolderPausedCapability(folderID, paused), + modify, + ) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() @@ -328,6 +357,9 @@ func ShareFolderWithDevice(folderID, deviceID string) string { if !exists { return "folder not found" } + if _, exists := stCfg.Devices()[devID]; !exists { + return "device not found" + } for _, d := range folder.Devices { if d.DeviceID == devID { @@ -335,7 +367,7 @@ func ShareFolderWithDevice(folderID, deviceID string) string { } } - waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + modify := func(cfg *config.Configuration) { for i, f := range cfg.Folders { if f.ID == folderID { cfg.Folders[i].Devices = append(cfg.Folders[i].Devices, config.FolderDeviceConfiguration{ @@ -344,9 +376,14 @@ func ShareFolderWithDevice(folderID, deviceID string) string { break } } - }) + } + waiter, err := modifyFolderConfiguration( + folder, + config.NewVaultSyncShareFolderCapability(folderID, devID), + modify, + ) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() @@ -373,7 +410,19 @@ func UnshareFolderFromDevice(folderID, deviceID string) string { return "cannot unshare from own device" } - waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + folder, exists := stCfg.Folders()[folderID] + if !exists { + return "folder not found" + } + if !folderHasConfiguredDevice(folder, devID) { + if receiveSideReadOnlyForFolderType(folder.Type) { + return conflictRetentionSafetyMarker + } + // Preserve the existing idempotent SendOnly no-op. + return "" + } + + modify := func(cfg *config.Configuration) { for i, f := range cfg.Folders { if f.ID == folderID { devices := make([]config.FolderDeviceConfiguration, 0, len(f.Devices)) @@ -386,11 +435,39 @@ func UnshareFolderFromDevice(folderID, deviceID string) string { break } } - }) + } + waiter, err := modifyFolderConfiguration( + folder, + config.NewVaultSyncUnshareFolderCapability(folderID, devID), + modify, + ) if err != nil { - return fmt.Sprintf("modify config: %v", err) + return folderConfigurationError(err) } waiter.Wait() return "" } + +func modifyFolderConfiguration(folder config.FolderConfiguration, capability config.VaultSyncConfigCapability, modify config.ModifyFunction) (config.Waiter, error) { + if receiveSideReadOnlyForFolderType(folder.Type) { + return config.ModifyWithVaultSyncCapability(stCfg, capability, modify) + } + return stCfg.Modify(modify) +} + +func folderConfigurationError(err error) string { + if errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { + return conflictRetentionSafetyMarker + } + return fmt.Sprintf("modify config: %v", err) +} + +func folderHasConfiguredDevice(folder config.FolderConfiguration, deviceID protocol.DeviceID) bool { + for _, device := range folder.Devices { + if device.DeviceID == deviceID { + return true + } + } + return false +} diff --git a/go/bridge/folders_test.go b/go/bridge/folders_test.go index b5e1aa3..d67d7b2 100644 --- a/go/bridge/folders_test.go +++ b/go/bridge/folders_test.go @@ -7,13 +7,40 @@ import ( "os" "path/filepath" "testing" + + "github.com/syncthing/syncthing/lib/config" ) +func addSendOnlyFolderForTesting(t *testing.T, id, label, path string) { + t.Helper() + if errMsg := addFolderForTesting(id, label, path); errMsg != "" { + t.Fatalf("add folder fixture: %s", errMsg) + } + + found := false + waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + for index := range cfg.Folders { + if cfg.Folders[index].ID == id { + cfg.Folders[index].Type = config.FolderTypeSendOnly + found = true + return + } + } + }) + if err != nil { + t.Fatalf("configure send-only folder fixture: %v", err) + } + waiter.Wait() + if !found { + t.Fatalf("folder fixture %q disappeared before configuring send-only type", id) + } +} + func TestAddRemoveFolder(t *testing.T) { configDir := testConfigDir(t) // Should fail when not running. - if errMsg := AddFolder("test", "Test", "/tmp"); errMsg != "syncthing not running" { + if errMsg := addFolderForTesting("test", "Test", "/tmp"); errMsg != "syncthing not running" { t.Fatalf("AddFolder when stopped = %q, want 'syncthing not running'", errMsg) } @@ -23,13 +50,13 @@ func TestAddRemoveFolder(t *testing.T) { defer StopSyncthing() // Empty ID should fail. - if errMsg := AddFolder("", "Test", "/tmp"); errMsg != "folder ID is required" { + if errMsg := addFolderForTesting("", "Test", "/tmp"); errMsg != "folder ID is required" { t.Fatalf("AddFolder empty ID = %q, want 'folder ID is required'", errMsg) } // Add a folder. folderPath := filepath.Join(configDir, "testfolder") - if errMsg := AddFolder("test-folder", "Test Folder", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("test-folder", "Test Folder", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -39,7 +66,7 @@ func TestAddRemoveFolder(t *testing.T) { } // Duplicate add should fail. - if errMsg := AddFolder("test-folder", "Dup", folderPath); errMsg != "folder already exists" { + if errMsg := addFolderForTesting("test-folder", "Dup", folderPath); errMsg != "folder already exists" { t.Fatalf("duplicate AddFolder = %q, want 'folder already exists'", errMsg) } @@ -86,25 +113,25 @@ func TestAddFolderPathOverlapRejected(t *testing.T) { defer StopSyncthing() vaultPath := filepath.Join(configDir, "VaultA") - if errMsg := AddFolder("vault-a", "Vault A", vaultPath); errMsg != "" { + if errMsg := addFolderForTesting("vault-a", "Vault A", vaultPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } // AddFolder enforces the same overlap floor as AcceptPendingFolder: equal, // nested, and containing paths are all rejected (issue #45). - if errMsg := AddFolder("vault-b", "Same", vaultPath); errMsg != "another folder already syncs to this path" { + if errMsg := addFolderForTesting("vault-b", "Same", vaultPath); errMsg != "another folder already syncs to this path" { t.Fatalf("AddFolder same path = %q, want collision error", errMsg) } nested := filepath.Join(vaultPath, "Inner") - if errMsg := AddFolder("vault-c", "Inner", nested); errMsg != "this path is inside a directory another folder already syncs" { + if errMsg := addFolderForTesting("vault-c", "Inner", nested); errMsg != "this path is inside a directory another folder already syncs" { t.Fatalf("AddFolder nested path = %q, want nested error", errMsg) } - if errMsg := AddFolder("vault-d", "Parent", configDir); errMsg != "another folder already syncs a directory inside this path" { + if errMsg := addFolderForTesting("vault-d", "Parent", configDir); errMsg != "another folder already syncs a directory inside this path" { t.Fatalf("AddFolder containing path = %q, want containing error", errMsg) } // A distinct sibling is still accepted. - if errMsg := AddFolder("vault-e", "Sibling", filepath.Join(configDir, "VaultB")); errMsg != "" { + if errMsg := addFolderForTesting("vault-e", "Sibling", filepath.Join(configDir, "VaultB")); errMsg != "" { t.Fatalf("AddFolder sibling = %q, want success", errMsg) } } @@ -119,7 +146,7 @@ func TestShareFolderWithDevice(t *testing.T) { // Add a folder and a device. folderPath := filepath.Join(configDir, "shared") - if errMsg := AddFolder("shared", "Shared", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("shared", "Shared", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -190,9 +217,7 @@ func TestSetFolderPath(t *testing.T) { defer StopSyncthing() pathA := filepath.Join(configDir, "vaultA") - if errMsg := AddFolder("pathtest", "Path Test", pathA); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } + addSendOnlyFolderForTesting(t, "pathtest", "Path Test", pathA) // Share with a device so we can assert the share survives the path change. testDeviceID := "MFZWI3D-BONSGYC-YLTMRWG-C43ENR5-QXGZDMM-FZWI3DP-BONSGYY-LTMRWAD" @@ -204,8 +229,8 @@ func TestSetFolderPath(t *testing.T) { } // Unknown folder. - if errMsg := SetFolderPath("nope", pathA); errMsg != "folder not found" { - t.Fatalf("SetFolderPath unknown = %q, want 'folder not found'", errMsg) + if errMsg := SetFolderPath("nope", pathA); errMsg != conflictRetentionSafetyMarker { + t.Fatalf("SetFolderPath unknown = %q, want fixed safety stop", errMsg) } // No-op when the path is unchanged. @@ -229,7 +254,7 @@ func TestSetFolderPath(t *testing.T) { } // An existing but empty directory is refused: it lacks this folder's marker, - // and pointing a send-receive folder there would propagate deletions. + // and pointing the folder there would make the configured index ambiguous. emptyDir := filepath.Join(configDir, "emptyVault") if err := os.MkdirAll(emptyDir, 0o700); err != nil { t.Fatalf("MkdirAll: %v", err) @@ -283,7 +308,7 @@ func TestSetFolderPaused(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "pausetest") - if errMsg := AddFolder("pausetest", "Pause Test", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("pausetest", "Pause Test", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -355,9 +380,7 @@ func TestEnsureDefaultIgnores(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "ensuretest") - if errMsg := AddFolder("ensuretest", "Ensure Test", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } + addSendOnlyFolderForTesting(t, "ensuretest", "Ensure Test", folderPath) defaults := []string{".Trash", ".obsidian/workspace.json"} defaultsJSON, _ := json.Marshal(defaults) @@ -399,8 +422,8 @@ func TestEnsureDefaultIgnores(t *testing.T) { } // (d) Unknown folder. - if errMsg := EnsureDefaultIgnores("nope", string(defaultsJSON)); errMsg != "folder not found" { - t.Fatalf("EnsureDefaultIgnores unknown = %q, want 'folder not found'", errMsg) + if errMsg := EnsureDefaultIgnores("nope", string(defaultsJSON)); errMsg != conflictRetentionSafetyMarker { + t.Fatalf("EnsureDefaultIgnores unknown = %q, want fixed safety stop", errMsg) } // (e) Invalid JSON. @@ -464,7 +487,7 @@ func TestGetFolderStatusJSON(t *testing.T) { // Add a folder so we can query its status. folderPath := filepath.Join(configDir, "statustest") - if errMsg := AddFolder("statustest", "Status Test", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("statustest", "Status Test", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -497,9 +520,7 @@ func TestFolderIgnores(t *testing.T) { // Add a folder. folderPath := filepath.Join(configDir, "ignoretest") - if errMsg := AddFolder("ignoretest", "Ignore Test", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } + addSendOnlyFolderForTesting(t, "ignoretest", "Ignore Test", folderPath) // Set ignores. ignores := []string{"*.tmp", ".DS_Store", "*.sync-conflict-*"} @@ -544,9 +565,7 @@ func TestRescanFolder(t *testing.T) { // Add a folder. folderPath := filepath.Join(configDir, "rescantest") - if errMsg := AddFolder("rescantest", "Rescan Test", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } + addSendOnlyFolderForTesting(t, "rescantest", "Rescan Test", folderPath) // Rescan should succeed. if errMsg := RescanFolder("rescantest"); errMsg != "" { diff --git a/go/bridge/folderscan_test.go b/go/bridge/folderscan_test.go index 9c00400..039d2fb 100644 --- a/go/bridge/folderscan_test.go +++ b/go/bridge/folderscan_test.go @@ -27,7 +27,7 @@ func TestScanFolderForKnownPatternsDetectsGitDirectory(t *testing.T) { } folderID := "scan-git" - if errMsg := AddFolder(folderID, "Scan Git", vault); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Scan Git", vault); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } @@ -63,7 +63,7 @@ func TestScanFolderForKnownPatternsEmptyVault(t *testing.T) { vault := t.TempDir() folderID := "scan-empty" - if errMsg := AddFolder(folderID, "Empty", vault); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Empty", vault); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } @@ -105,7 +105,7 @@ func TestScanFolderForKnownPatternsMultipleCandidates(t *testing.T) { } folderID := "scan-multi" - if errMsg := AddFolder(folderID, "Multi", vault); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Multi", vault); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } @@ -161,7 +161,7 @@ func TestScanFolderForKnownPatternsAggregatesNestedVaults(t *testing.T) { } folderID := "scan-nested" - if errMsg := AddFolder(folderID, "Nested", root); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Nested", root); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } @@ -215,7 +215,7 @@ func TestScanFolderForKnownPatternsSkipsHiddenSubdirs(t *testing.T) { } folderID := "scan-hidden" - if errMsg := AddFolder(folderID, "Hidden", root); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Hidden", root); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } @@ -248,7 +248,7 @@ func TestScanFolderForKnownPatternsIgnoresEmptyDirectories(t *testing.T) { } folderID := "scan-empty-git" - if errMsg := AddFolder(folderID, "Empty Git", vault); errMsg != "" { + if errMsg := addFolderForTesting(folderID, "Empty Git", vault); errMsg != "" { t.Fatalf("AddFolder: %s", errMsg) } diff --git a/go/bridge/folderstatus.go b/go/bridge/folderstatus.go index a494c5a..98218d0 100644 --- a/go/bridge/folderstatus.go +++ b/go/bridge/folderstatus.go @@ -10,6 +10,7 @@ import ( "time" "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/model" "github.com/syncthing/syncthing/lib/protocol" "github.com/syncthing/syncthing/lib/syncthing" ) @@ -53,6 +54,19 @@ func getFolderConfigs() map[string]config.FolderConfiguration { return stCfg.Folders() } +func folderStatusRequiresCompleteSafetyEvidence(folderID string) bool { + folders := getFolderConfigs() + if folders == nil { + return true + } + folder, exists := folders[folderID] + return folderTypeRequiresCompleteSafetyEvidence(folder.Type, exists) +} + +func folderTypeRequiresCompleteSafetyEvidence(folderType config.FolderType, exists bool) bool { + return !exists || receiveSideReadOnlyForFolderType(folderType) +} + // GetFolderStatusJSON returns the sync status of a folder as JSON. // Includes state (idle/scanning/syncing/error), completion percentage, and file counts. func GetFolderStatusJSON(folderID string) string { @@ -60,16 +74,30 @@ func GetFolderStatusJSON(folderID string) string { if internals == nil { return "{}" } + requireCompleteSafetyEvidence := folderStatusRequiresCompleteSafetyEvidence(folderID) status := FolderStatus{} - // Get folder state (idle, scanning, syncing, error, etc.). - state, stateChanged, err := internals.FolderState(folderID) - if err != nil { - status.State = "error" + // Obtain state and item-level error evidence independently. A safety stop can + // be the reason FolderState itself fails, and FolderErrors can fail while the + // cached runner state still says idle. Resolve both before any success-shaped + // return so neither condition can expose raw paths or hide missing evidence. + state, stateChanged, stateErr := internals.FolderState(folderID) + status.State = state + if stateErr != nil || stateChanged.IsZero() { status.StateChanged = time.Now().Format("2006-01-02T15:04:05Z07:00") - status.ErrorReason = classifyFolderErrorReason(err.Error()) - status.ErrorMessage = err.Error() + } else { + status.StateChanged = stateChanged.Format("2006-01-02T15:04:05Z07:00") + } + folderErrors, folderErrorsErr := internals.FolderErrors(folderID) + if applyFolderSafetyEvidence(&status, stateErr, folderErrors, folderErrorsErr, requireCompleteSafetyEvidence) { + return marshalFolderStatus(status) + } + + if stateErr != nil { + status.State = "error" + status.ErrorReason = classifyFolderErrorReason(stateErr.Error()) + status.ErrorMessage = stateErr.Error() if inferred, ok := inferFolderPathErrorDetail(folderID); ok { if status.ErrorReason == "" || status.ErrorReason == "unknown_error" { @@ -87,8 +115,6 @@ func GetFolderStatusJSON(folderID string) string { } return marshalFolderStatus(status) } - status.State = state - status.StateChanged = stateChanged.Format("2006-01-02T15:04:05Z07:00") // Some Syncthing internals return an empty state for unknown folders // instead of an explicit error. Normalize this into an error contract. @@ -109,12 +135,8 @@ func GetFolderStatusJSON(folderID string) string { // Get completion for local device (how much of global state we have). completion, err := internals.Completion(protocol.LocalDeviceID, folderID) - if err == nil { - status.CompletionPct = completion.CompletionPct - status.NeedBytes = completion.NeedBytes - status.NeedFiles = completion.NeedItems - status.GlobalBytes = completion.GlobalBytes - status.GlobalFiles = completion.GlobalItems + if !applyFolderCompletionEvidence(&status, completion, err, requireCompleteSafetyEvidence) { + return marshalFolderStatus(status) } // Get local file counts. @@ -156,6 +178,81 @@ func GetFolderStatusJSON(folderID string) string { return marshalFolderStatus(status) } +func applyFolderCompletionEvidence(status *FolderStatus, completion model.FolderCompletion, err error, requireCompleteEvidence bool) bool { + if err == nil { + status.CompletionPct = completion.CompletionPct + status.NeedBytes = completion.NeedBytes + status.NeedFiles = completion.NeedItems + status.GlobalBytes = completion.GlobalBytes + status.GlobalFiles = completion.GlobalItems + return true + } + if isConflictRetentionSafetyError(err.Error()) { + applyConflictRetentionSafetyStatus(status, []model.FileError{{Err: err.Error()}}) + return false + } + if !requireCompleteEvidence { + return true + } + + status.State = "error" + status.ErrorReason = folderCompletionEvidenceUnavailableReason + status.ErrorMessage = folderCompletionEvidenceUnavailableMessage + status.ErrorPath = "" + if status.ErrorChanged == "" { + status.ErrorChanged = status.StateChanged + } + return false +} + +// applyFolderSafetyEvidence resolves the two safety-bearing evidence channels +// before normal state handling. Exact safety sentinels remain terminal for all +// folder types. Missing ordinary evidence is terminal only when receive-side +// behavior is possible; SendOnly keeps Syncthing's existing state diagnosis. +// Every terminal status is fixed and path-free because upstream errors may +// contain user-derived names and paths. +func applyFolderSafetyEvidence(status *FolderStatus, stateErr error, folderErrors []model.FileError, folderErrorsErr error, requireCompleteEvidence bool) bool { + if stateErr != nil && isConflictRetentionSafetyError(stateErr.Error()) { + applyConflictRetentionSafetyStatus(status, []model.FileError{{Err: stateErr.Error()}}) + return true + } + if applyConflictRetentionSafetyStatus(status, folderErrors) { + return true + } + if folderErrorsErr == nil { + return false + } + if !requireCompleteEvidence { + return false + } + + status.State = "error" + status.ErrorReason = folderErrorEvidenceUnavailableReason + status.ErrorMessage = folderErrorEvidenceUnavailableMessage + status.ErrorPath = "" + if status.ErrorChanged == "" { + status.ErrorChanged = status.StateChanged + } + return true +} + +func applyConflictRetentionSafetyStatus(status *FolderStatus, folderErrors []model.FileError) bool { + for _, folderError := range folderErrors { + if !isConflictRetentionSafetyError(folderError.Err) { + continue + } + status.State = "error" + status.ErrorReason = conflictRetentionSafetyErrorReason + status.ErrorMessage = conflictRetentionSafetyErrorMessage + status.ErrorPath = "" + if status.ErrorChanged == "" { + status.ErrorChanged = status.StateChanged + } + return true + } + return false +} + // GetFolderIgnores returns the .stignore lines for a folder as a JSON array. // Reads the .stignore file directly from disk to avoid model cache staleness. func GetFolderIgnores(folderID string) string { @@ -195,6 +292,9 @@ func GetFolderIgnores(folderID string) string { // ignoresJSON must be a JSON array of strings, e.g. ["*.tmp", ".DS_Store"]. // Returns empty string on success, error message on failure. func SetFolderIgnores(folderID, ignoresJSON string) string { + if receiveSideReadOnlyFolder(folderID) { + return conflictRetentionSafetyMarker + } internals := getInternals() if internals == nil { return "syncthing not running" @@ -224,6 +324,9 @@ func SetFolderIgnores(folderID, ignoresJSON string) string { // just the defaults. Returns empty string on success — including the no-op case // where every default is already present — or an error message on failure. func EnsureDefaultIgnores(folderID, defaultsJSON string) string { + if receiveSideReadOnlyFolder(folderID) { + return conflictRetentionSafetyMarker + } var defaults []string if err := json.Unmarshal([]byte(defaultsJSON), &defaults); err != nil { return fmt.Sprintf("invalid JSON: %v", err) @@ -287,18 +390,33 @@ func EnsureDefaultIgnores(folderID, defaultsJSON string) string { // RescanFolder triggers a rescan of all files in the folder. // Returns empty string on success, error message on failure. func RescanFolder(folderID string) string { + if receiveSideReadOnlyFolder(folderID) { + return conflictRetentionSafetyMarker + } internals := getInternals() if internals == nil { return "syncthing not running" } if err := internals.ScanFolderSubdirs(folderID, nil); err != nil { + if isConflictRetentionSafetyError(err.Error()) { + return conflictRetentionSafetyMarker + } return fmt.Sprintf("rescan: %v", err) } return "" } +func receiveSideReadOnlyFolder(folderID string) bool { + folders := getFolderConfigs() + if folders == nil { + return false + } + folder, exists := folders[folderID] + return !exists || receiveSideReadOnlyForFolderType(folder.Type) +} + func marshalFolderStatus(status FolderStatus) string { data, err := json.Marshal(status) if err != nil { diff --git a/go/bridge/folderstatus_test.go b/go/bridge/folderstatus_test.go index e03cbc7..a5630d6 100644 --- a/go/bridge/folderstatus_test.go +++ b/go/bridge/folderstatus_test.go @@ -2,10 +2,14 @@ package bridge import ( "encoding/json" + "errors" "os" "path/filepath" "strings" "testing" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/model" ) func TestDiagnosticsUploadPathAvailableIsExactAndReadOnly(t *testing.T) { @@ -16,9 +20,7 @@ func TestDiagnosticsUploadPathAvailableIsExactAndReadOnly(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "diagnostics-folder") - if errMsg := AddFolder("diagnostics-folder", "Diagnostics Folder", folderPath); errMsg != "" { - t.Fatalf("AddFolder failed: %s", errMsg) - } + addSendOnlyFolderForTesting(t, "diagnostics-folder", "Diagnostics Folder", folderPath) installation := strings.Repeat("a", 52) operation := strings.Repeat("b", 52) operationsPath := filepath.Join( @@ -93,7 +95,7 @@ func TestGetFolderStatusJSONHealthyFolderKeepsErrorFieldsEmpty(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "healthy-folder") - if errMsg := AddFolder("healthy-folder", "Healthy Folder", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("healthy-folder", "Healthy Folder", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -120,6 +122,7 @@ func TestClassifyFolderErrorReason(t *testing.T) { {"not a directory", "folder_path_invalid"}, {"no space left on device", "disk_full"}, {"connection refused", "network_error"}, + {"syncing: " + conflictRetentionSafetyMarker, "unknown_error"}, {"something else", "unknown_error"}, } @@ -129,3 +132,307 @@ func TestClassifyFolderErrorReason(t *testing.T) { } } } + +func TestIssue150Issue167ConflictRetentionSafetyStatusIsStableAndPathFree(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + CompletionPct: 91, + NeedBytes: 123, + NeedFiles: 1, + } + errors := []model.FileError{ + {Path: "other.md", Err: "permission denied"}, + {Path: secretPath, Err: conflictRetentionSafetyMarker}, + } + + if !applyConflictRetentionSafetyStatus(&status, errors) { + t.Fatal("safety marker behind another error was not detected") + } + first := status + if !applyConflictRetentionSafetyStatus(&status, errors) { + t.Fatal("second safety overlay did not detect the same marker") + } + if status != first { + t.Fatalf("repeated safety overlay changed status: first=%+v second=%+v", first, status) + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason { + t.Fatalf("unexpected safety status: %+v", status) + } + if status.ErrorMessage != conflictRetentionSafetyErrorMessage || status.ErrorPath != "" { + t.Fatalf("safety status exposed non-fixed detail: %+v", status) + } + if status.ErrorChanged != status.StateChanged { + t.Fatalf("errorChanged = %q, want stable stateChanged %q", status.ErrorChanged, status.StateChanged) + } + if status.NeedBytes != 123 || status.NeedFiles != 1 || status.CompletionPct != 91 { + t.Fatalf("safety overlay changed completion evidence: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) || strings.Contains(string(raw), conflictRetentionSafetyMarker) { + t.Fatalf("safety status leaked raw detail: %s", raw) + } +} + +func TestIssue150FolderStateSafetyErrorIsNormalizedBeforeEarlyReturn(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + status := FolderStatus{ + StateChanged: "2026-08-28T16:00:00+02:00", + ErrorPath: secretPath, + } + stateErr := errors.New(conflictRetentionSafetyMarker) + + if !applyFolderSafetyEvidence(&status, stateErr, nil, errors.New("runner unavailable"), false) { + t.Fatal("state safety error did not produce a terminal status") + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason { + t.Fatalf("unexpected safety status: %+v", status) + } + if status.ErrorMessage != conflictRetentionSafetyErrorMessage || status.ErrorPath != "" { + t.Fatalf("state safety error was not normalized: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) || strings.Contains(string(raw), conflictRetentionSafetyMarker) { + t.Fatalf("state safety status leaked raw detail: %s", raw) + } +} + +func TestIssue150FolderErrorSafetySentinelOverridesSendOnlyEvidencePolicy(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + ErrorMessage: secretPath, + ErrorPath: secretPath, + } + folderErrors := []model.FileError{{Path: secretPath, Err: conflictRetentionSafetyMarker}} + + if !applyFolderSafetyEvidence(&status, nil, folderErrors, errors.New("folder errors unavailable"), false) { + t.Fatal("exact folder-error sentinel did not override send-only evidence policy") + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason { + t.Fatalf("unexpected safety status: %+v", status) + } + if status.ErrorMessage != conflictRetentionSafetyErrorMessage || status.ErrorPath != "" { + t.Fatalf("folder-error safety status exposed non-fixed detail: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) || strings.Contains(string(raw), conflictRetentionSafetyMarker) { + t.Fatalf("folder-error safety status leaked raw detail: %s", raw) + } +} + +func TestIssue150FolderErrorEvidenceUnavailableFailsClosedAndPathFree(t *testing.T) { + secretPath := "redaction-probe/vault-note.md" + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + ErrorMessage: secretPath, + ErrorPath: secretPath, + CompletionPct: 100, + } + + if !applyFolderSafetyEvidence(&status, nil, nil, errors.New("read "+secretPath), true) { + t.Fatal("missing folder-error evidence did not produce a terminal status") + } + if status.State != "error" || status.ErrorReason != folderErrorEvidenceUnavailableReason { + t.Fatalf("unexpected evidence-unavailable status: %+v", status) + } + if status.ErrorMessage != folderErrorEvidenceUnavailableMessage || status.ErrorPath != "" { + t.Fatalf("evidence-unavailable status was not normalized: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) { + t.Fatalf("evidence-unavailable status leaked raw detail: %s", raw) + } +} + +func TestIssue150FolderCompletionEvidenceUnavailableFailsClosedAndPathFree(t *testing.T) { + secretPath := "redaction-probe/index.db" + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + CompletionPct: 100, + } + + if applyFolderCompletionEvidence(&status, model.FolderCompletion{}, errors.New("read "+secretPath), true) { + t.Fatal("missing completion evidence was accepted") + } + if status.State != "error" || status.ErrorReason != folderCompletionEvidenceUnavailableReason { + t.Fatalf("unexpected completion-evidence status: %+v", status) + } + if status.ErrorMessage != folderCompletionEvidenceUnavailableMessage || status.ErrorPath != "" { + t.Fatalf("completion-evidence status was not normalized: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) { + t.Fatalf("completion-evidence status leaked raw detail: %s", raw) + } +} + +func TestIssue150SendOnlyOrdinaryFolderErrorsPreserveStateDiagnostics(t *testing.T) { + tests := []struct { + name string + stateError string + wantReason string + }{ + {name: "paused", stateError: "folder is paused", wantReason: "unknown_error"}, + {name: "not running", stateError: "folder not running", wantReason: "unknown_error"}, + {name: "permission", stateError: "permission denied", wantReason: "permission_denied"}, + {name: "marker", stateError: "folder marker missing", wantReason: "unknown_error"}, + {name: "disk", stateError: "no space left on device", wantReason: "disk_full"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stateErr := errors.New(test.stateError) + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + } + before := status + + if applyFolderSafetyEvidence(&status, stateErr, nil, errors.New("folder errors unavailable"), false) { + t.Fatalf("ordinary send-only evidence became terminal: %+v", status) + } + if status != before { + t.Fatalf("ordinary send-only evidence changed status: before=%+v after=%+v", before, status) + } + + status.State = "error" + status.ErrorReason = classifyFolderErrorReason(stateErr.Error()) + status.ErrorMessage = stateErr.Error() + if status.ErrorReason != test.wantReason || status.ErrorMessage != test.stateError { + t.Fatalf("normal state diagnosis was not preserved: %+v", status) + } + if status.ErrorReason == folderErrorEvidenceUnavailableReason || status.ErrorReason == conflictRetentionSafetyErrorReason { + t.Fatalf("ordinary send-only diagnosis was recast as #150 evidence: %+v", status) + } + }) + } +} + +func TestIssue150SendOnlyCompletionUnavailablePreservesExistingStatus(t *testing.T) { + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + ErrorReason: "permission_denied", + ErrorMessage: "permission denied", + ErrorChanged: "2026-08-28T15:59:00+02:00", + LocalBytes: 123, + LocalFiles: 4, + CompletionPct: 17, + InProgressBytes: 5, + } + before := status + + if !applyFolderCompletionEvidence(&status, model.FolderCompletion{}, errors.New("folder not running"), false) { + t.Fatalf("ordinary send-only completion error became terminal: %+v", status) + } + if status != before { + t.Fatalf("ordinary send-only completion error changed status: before=%+v after=%+v", before, status) + } +} + +func TestIssue150ReceiveCapableAndUnknownFolderStatusEvidenceRemainFailClosed(t *testing.T) { + tests := []struct { + name string + folderType config.FolderType + exists bool + want bool + }{ + {name: "send only", folderType: config.FolderTypeSendOnly, exists: true, want: false}, + {name: "send receive", folderType: config.FolderTypeSendReceive, exists: true, want: true}, + {name: "receive only", folderType: config.FolderTypeReceiveOnly, exists: true, want: true}, + {name: "receive encrypted", folderType: config.FolderTypeReceiveEncrypted, exists: true, want: true}, + {name: "unknown type", folderType: config.FolderType(127), exists: true, want: true}, + {name: "unknown folder", folderType: config.FolderTypeSendOnly, exists: false, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := folderTypeRequiresCompleteSafetyEvidence(test.folderType, test.exists); got != test.want { + t.Fatalf("folderTypeRequiresCompleteSafetyEvidence(%v, %v) = %v, want %v", test.folderType, test.exists, got, test.want) + } + }) + } +} + +func TestIssue150ExactSafetySentinelOverridesSendOnlyCompletionAndStaysPathFree(t *testing.T) { + secretPath := "redaction-probe/index.db" + status := FolderStatus{ + State: "idle", + StateChanged: "2026-08-28T16:00:00+02:00", + ErrorMessage: secretPath, + ErrorPath: secretPath, + } + + if applyFolderCompletionEvidence(&status, model.FolderCompletion{}, errors.New(conflictRetentionSafetyMarker), false) { + t.Fatal("exact safety sentinel was accepted for a send-only completion error") + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason { + t.Fatalf("unexpected safety status: %+v", status) + } + if status.ErrorMessage != conflictRetentionSafetyErrorMessage || status.ErrorPath != "" { + t.Fatalf("send-only safety status exposed non-fixed detail: %+v", status) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secretPath) || strings.Contains(string(raw), conflictRetentionSafetyMarker) { + t.Fatalf("send-only safety status leaked raw detail: %s", raw) + } +} + +func TestIssue150CompleteFolderEvidencePopulatesSettlementFields(t *testing.T) { + status := FolderStatus{State: "idle"} + completion := model.FolderCompletion{ + CompletionPct: 87, + NeedBytes: 123, + NeedItems: 4, + GlobalBytes: 456, + GlobalItems: 7, + } + + if !applyFolderCompletionEvidence(&status, completion, nil, true) { + t.Fatal("valid completion evidence was rejected") + } + if status.State != "idle" || status.CompletionPct != 87 || status.NeedBytes != 123 || status.NeedFiles != 4 || status.GlobalBytes != 456 || status.GlobalFiles != 7 { + t.Fatalf("completion evidence was not copied exactly: %+v", status) + } +} + +func TestIssue150SafetyMarkerRequiresExactErrorValue(t *testing.T) { + for _, message := range []string{ + "prefix " + conflictRetentionSafetyMarker, + conflictRetentionSafetyMarker + " suffix", + "redaction-probe/" + conflictRetentionSafetyMarker + ".md", + } { + if isConflictRetentionSafetyError(message) { + t.Fatalf("non-exact marker value was accepted: %q", message) + } + if got := classifyFolderErrorReason(message); got == conflictRetentionSafetyErrorReason { + t.Fatalf("non-exact marker classified as safety stop: %q", message) + } + } + if !isConflictRetentionSafetyError(" " + conflictRetentionSafetyMarker + "\n") { + t.Fatal("whitespace-wrapped exact marker was rejected") + } +} diff --git a/go/bridge/issue150_bridge_restart_test.go b/go/bridge/issue150_bridge_restart_test.go new file mode 100644 index 0000000..99686e2 --- /dev/null +++ b/go/bridge/issue150_bridge_restart_test.go @@ -0,0 +1,335 @@ +package bridge + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/protocol" + "github.com/syncthing/syncthing/lib/syncthing" +) + +func TestIssue150PublicBridgeSameHomeRestartPreservesIdentityCompleteConfigNeedAndVault(t *testing.T) { + StopSyncthing() + previousDefaultListenAddresses := append([]string(nil), config.DefaultListenAddresses...) + config.DefaultListenAddresses = []string{"tcp://127.0.0.1:0"} + t.Cleanup(func() { config.DefaultListenAddresses = previousDefaultListenAddresses }) + + configDir := testConfigDir(t) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("bootstrap StartSyncthing: %s", got) + } + localID, err := protocol.DeviceIDFromString(DeviceID()) + if err != nil { + t.Fatalf("parse bootstrap identity: %v", err) + } + if err := stCfg.Save(); err != nil { + t.Fatalf("save bootstrap config: %v", err) + } + StopSyncthing() + + const folderID = "issue150-public-bridge-restart" + providerCert := issue150TestCertificate(t) + providerID := protocol.NewDeviceID(providerCert.Certificate[0]) + providerHome := t.TempDir() + providerVault := filepath.Join(providerHome, "provider-vault") + if err := os.MkdirAll(providerVault, 0o700); err != nil { + t.Fatalf("create provider vault: %v", err) + } + if err := os.WriteFile( + filepath.Join(providerVault, "remote-note.sync-conflict-20260829-120000.md"), + []byte("remote issue 150 bytes\n"), + 0o600, + ); err != nil { + t.Fatalf("write provider file: %v", err) + } + providerRaw := issue150HermeticConfig(providerID) + providerRaw.Options.RawListenAddresses = []string{issue150AvailableTCPListenAddress(t)} + providerRaw.SetDevice(config.DeviceConfiguration{ + DeviceID: localID, + Name: "Issue 150 bridge peer", + Addresses: []string{"dynamic"}, + }) + providerFolder := providerRaw.Defaults.Folder.Copy() + providerFolder.ID = folderID + providerFolder.Label = "Issue 150 provider folder" + providerFolder.Path = providerVault + providerFolder.Type = config.FolderTypeSendOnly + providerFolder.RescanIntervalS = 60 + providerFolder.FSWatcherEnabled = false + providerFolder.IgnorePerms = true + providerFolder.MaxConflicts = 17 + providerFolder.Devices = []config.FolderDeviceConfiguration{ + {DeviceID: providerID}, + {DeviceID: localID}, + } + providerRaw.SetFolder(providerFolder) + provider, providerEvents := issue150StartHermeticApp(t, providerHome, providerRaw, providerCert, false) + providerAddress := issue150WaitForTCPListenAddress(t, providerEvents) + + localVault := t.TempDir() + if err := os.MkdirAll(filepath.Join(localVault, "notes"), 0o700); err != nil { + t.Fatalf("create local notes directory: %v", err) + } + if err := os.MkdirAll(filepath.Join(localVault, ".stversions", "archive"), 0o700); err != nil { + t.Fatalf("create local versions directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(localVault, "notes", "local-note.sync-conflict-20260829-120001.md"), + []byte("local issue 150 bytes\n"), + 0o600, + ); err != nil { + t.Fatalf("write local conflict-shaped file: %v", err) + } + if err := os.WriteFile( + filepath.Join(localVault, ".stversions", "archive", "retained.sync-conflict-20260829-120002.md"), + []byte("retained issue 150 version bytes\n"), + 0o640, + ); err != nil { + t.Fatalf("write local retained version: %v", err) + } + vaultBefore := issue150SnapshotBridgeVault(t, localVault) + + issue150WriteStoppedBridgeConfiguration(t, configDir, localID, func(cfg *config.Configuration) { + // Keep the public bridge listener TCP-only. StartSyncthing still owns + // the normal embedded-service options; this avoids unrelated QUIC/STUN + // activity in the regression fixture. + cfg.Options.RawListenAddresses = []string{"tcp://127.0.0.1:0"} + + providerDevice := cfg.Defaults.Device.Copy() + providerDevice.DeviceID = providerID + providerDevice.Name = "Issue 150 configured provider" + // A single static address is user-managed, so the existing address-cache + // policy must leave it byte-for-byte unchanged. + providerDevice.Addresses = []string{providerAddress} + providerDevice.MaxRequestKiB = 512 + providerDevice.RawNumConnections = 1 + cfg.SetDevice(providerDevice) + + folder := cfg.Defaults.Folder.Copy() + folder.ID = folderID + folder.Label = "Issue 150 complete receive configuration" + folder.Path = localVault + folder.Type = config.FolderTypeReceiveOnly + folder.RescanIntervalS = 60 + folder.FSWatcherEnabled = false + folder.FSWatcherDelayS = 13 + folder.FSWatcherTimeoutS = 29 + folder.IgnorePerms = true + folder.AutoNormalize = false + folder.IgnoreDelete = true + folder.MaxConflicts = 23 + folder.DisableSparseFiles = true + folder.RawModTimeWindowS = 2 + folder.MaxConcurrentWrites = 2 + folder.DisableFsync = true + folder.Devices = []config.FolderDeviceConfiguration{ + {DeviceID: localID}, + {DeviceID: providerID}, + } + cfg.SetFolder(folder) + }) + + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("first receive StartSyncthing: %s", got) + } + t.Cleanup(StopSyncthing) + firstIdentity := DeviceID() + firstCert := issue150ReadBridgeFile(t, filepath.Join(configDir, "cert.pem")) + firstKey := issue150ReadBridgeFile(t, filepath.Join(configDir, "key.pem")) + issue150WaitForBridgeNeed(t, folderID, 1) + firstNeed := issue150BridgeNeed(t, folderID) + if firstNeed.Files != 1 || firstNeed.Directories != 0 || firstNeed.Symlinks != 0 || firstNeed.Deleted != 0 { + t.Fatalf("first lifecycle need = %+v, want exactly one remote file", firstNeed) + } + if localSize, err := stApp.Internals.LocalSize(folderID); err != nil || localSize.TotalItems() != 0 { + t.Fatalf("first lifecycle local index = %+v, error=%v, want empty", localSize, err) + } + issue150AssertBridgeSafetyStatus(t, folderID) + if got := issue150SnapshotBridgeVault(t, localVault); !reflect.DeepEqual(got, vaultBefore) { + t.Fatalf("first public lifecycle mutated the receive vault:\nbefore=%+v\nafter=%+v", vaultBefore, got) + } + + firstConfig := stCfg.RawCopy() + if err := stCfg.Save(); err != nil { + t.Fatalf("save first lifecycle config: %v", err) + } + provider.stop(t) + StopSyncthing() + firstConfigBytes := issue150ReadBridgeFile(t, filepath.Join(configDir, "config.xml")) + + // The provider stays stopped. Any Need visible after this public close/open + // cycle therefore came from the persisted remote index, not a replayed + // network message. + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("same-home StartSyncthing: %s", got) + } + if got := DeviceID(); got != firstIdentity { + t.Fatalf("same-home identity changed: got %s want %s", got, firstIdentity) + } + if got := issue150ReadBridgeFile(t, filepath.Join(configDir, "cert.pem")); !bytes.Equal(got, firstCert) { + t.Fatal("same-home restart replaced the persisted certificate") + } + if got := issue150ReadBridgeFile(t, filepath.Join(configDir, "key.pem")); !bytes.Equal(got, firstKey) { + t.Fatal("same-home restart replaced the persisted private key") + } + issue150WaitForBridgeNeed(t, folderID, 1) + secondNeed := issue150BridgeNeed(t, folderID) + if secondNeed != firstNeed { + t.Fatalf("persisted Need changed across provider-offline restart:\nbefore=%+v\nafter=%+v", firstNeed, secondNeed) + } + if localSize, err := stApp.Internals.LocalSize(folderID); err != nil || localSize.TotalItems() != 0 { + t.Fatalf("restarted lifecycle local index = %+v, error=%v, want empty", localSize, err) + } + + secondConfig := stCfg.RawCopy() + if !reflect.DeepEqual(secondConfig.Folders, firstConfig.Folders) { + t.Fatalf("complete folder configuration changed across public restart:\nbefore=%+v\nafter=%+v", firstConfig.Folders, secondConfig.Folders) + } + if !reflect.DeepEqual(secondConfig.Devices, firstConfig.Devices) { + t.Fatalf("complete device configuration changed across public restart:\nbefore=%+v\nafter=%+v", firstConfig.Devices, secondConfig.Devices) + } + if err := stCfg.Save(); err != nil { + t.Fatalf("save restarted lifecycle config: %v", err) + } + secondConfigBytes := issue150ReadBridgeFile(t, filepath.Join(configDir, "config.xml")) + if !bytes.Equal(secondConfigBytes, firstConfigBytes) { + t.Fatalf("config.xml bytes changed across provider-offline public restart:\nbefore-sha256=%x\nafter-sha256=%x", sha256.Sum256(firstConfigBytes), sha256.Sum256(secondConfigBytes)) + } + issue150AssertBridgeSafetyStatus(t, folderID) + if got := issue150SnapshotBridgeVault(t, localVault); !reflect.DeepEqual(got, vaultBefore) { + t.Fatalf("same-home public restart mutated the receive vault:\nbefore=%+v\nafter=%+v", vaultBefore, got) + } + if _, err := os.Lstat(filepath.Join(localVault, config.DefaultMarkerName)); !os.IsNotExist(err) { + t.Fatalf("public bridge lifecycle created a receive marker: %v", err) + } + if _, err := os.Lstat(filepath.Join(localVault, "remote-note.sync-conflict-20260829-120000.md")); !os.IsNotExist(err) { + t.Fatalf("public bridge lifecycle materialized the remote file: %v", err) + } +} + +type issue150BridgeVaultEntry struct { + Mode os.FileMode + Size int64 + ModTimeNS int64 + ContentSHA [sha256.Size]byte +} + +func issue150SnapshotBridgeVault(t *testing.T, root string) map[string]issue150BridgeVaultEntry { + t.Helper() + result := make(map[string]issue150BridgeVaultEntry) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + snapshot := issue150BridgeVaultEntry{ + Mode: info.Mode(), + Size: info.Size(), + ModTimeNS: info.ModTime().UnixNano(), + } + if info.Mode().IsRegular() { + content, err := os.ReadFile(path) + if err != nil { + return err + } + snapshot.ContentSHA = sha256.Sum256(content) + } + result[relative] = snapshot + return nil + }) + if err != nil { + t.Fatalf("snapshot receive vault: %v", err) + } + return result +} + +func issue150WriteStoppedBridgeConfiguration(t *testing.T, configDir string, localID protocol.DeviceID, modify func(*config.Configuration)) { + t.Helper() + path := filepath.Join(configDir, "config.xml") + input, err := os.Open(path) + if err != nil { + t.Fatalf("open stopped bridge config: %v", err) + } + cfg, _, err := config.ReadXML(input, localID) + closeErr := input.Close() + if err != nil { + t.Fatalf("decode stopped bridge config: %v", err) + } + if closeErr != nil { + t.Fatalf("close stopped bridge config: %v", closeErr) + } + modify(&cfg) + var encoded bytes.Buffer + if err := cfg.WriteXML(&encoded); err != nil { + t.Fatalf("encode stopped bridge config: %v", err) + } + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("write stopped bridge config: %v", err) + } +} + +func issue150ReadBridgeFile(t *testing.T, path string) []byte { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read bridge lifecycle file: %v", err) + } + return content +} + +func issue150BridgeNeed(t *testing.T, folderID string) syncthing.Counts { + t.Helper() + mu.Lock() + app := stApp + running := stRunning + mu.Unlock() + if !running || app == nil { + t.Fatal("public bridge is not running while reading Need") + } + need, err := app.Internals.NeedSize(folderID, protocol.LocalDeviceID) + if err != nil { + t.Fatalf("read public bridge Need: %v", err) + } + return need +} + +func issue150WaitForBridgeNeed(t *testing.T, folderID string, files int) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + need := issue150BridgeNeed(t, folderID) + if need.Files == files { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("public bridge Need did not stabilize at %d files: %+v", files, issue150BridgeNeed(t, folderID)) +} + +func issue150AssertBridgeSafetyStatus(t *testing.T, folderID string) { + t.Helper() + if got := RescanFolder(folderID); got != conflictRetentionSafetyMarker { + t.Fatalf("receive rescan = %q, want fixed safety stop", got) + } + var status FolderStatus + if err := json.Unmarshal([]byte(GetFolderStatusJSON(folderID)), &status); err != nil { + t.Fatalf("decode receive safety status: %v", err) + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason || status.ErrorPath != "" { + t.Fatalf("receive safety status is not stable and path-free: %+v", status) + } +} diff --git a/go/bridge/issue150_capability_test.go b/go/bridge/issue150_capability_test.go new file mode 100644 index 0000000..82ae892 --- /dev/null +++ b/go/bridge/issue150_capability_test.go @@ -0,0 +1,458 @@ +package bridge + +import ( + "crypto/tls" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/events" + "github.com/syncthing/syncthing/lib/locations" + "github.com/syncthing/syncthing/lib/protocol" + "github.com/syncthing/syncthing/lib/tlsutil" +) + +func TestIssue150ProtectedGenericConfigurationDiffsStopBeforeLiveMutation(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-capability-generic" + folderPath := filepath.Join(configDir, "protected-vault") + peerID := issue150SeedCapabilityFolderBeforeStart(t, configDir, folderID, folderPath, config.FolderTypeReceiveOnly, true) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start protected bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + + beforeConfig := stCfg.RawCopy() + beforeVault := issue150SnapshotBridgeVault(t, folderPath) + + tests := []struct { + name string + call func() error + }{ + { + name: "path", + call: func() error { + return issue150BridgeGenericModify(func(cfg *config.Configuration) { + for i := range cfg.Folders { + if cfg.Folders[i].ID == folderID { + cfg.Folders[i].Path = filepath.Join(configDir, "other-vault") + } + } + }) + }, + }, + { + name: "device extension", + call: func() error { + return issue150BridgeGenericModify(func(cfg *config.Configuration) { + for i := range cfg.Folders { + if cfg.Folders[i].ID == folderID { + cfg.Folders[i].Devices = append(cfg.Folders[i].Devices, config.FolderDeviceConfiguration{DeviceID: peerID}) + } + } + }) + }, + }, + { + name: "remove", + call: func() error { + waiter, err := stCfg.RemoveFolder(folderID) + waiter.Wait() + return err + }, + }, + { + name: "derived membership through device removal", + call: func() error { + waiter, err := stCfg.RemoveDevice(peerID) + waiter.Wait() + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + issue150ExpectCanonicalConfigurationSafetyStop(t, test.call()) + if after := stCfg.RawCopy(); !reflect.DeepEqual(after, beforeConfig) { + t.Fatalf("generic protected diff changed live config:\nbefore=%+v\nafter=%+v", beforeConfig, after) + } + if after := issue150SnapshotBridgeVault(t, folderPath); !reflect.DeepEqual(after, beforeVault) { + t.Fatalf("generic protected diff changed vault:\nbefore=%+v\nafter=%+v", beforeVault, after) + } + }) + } + + t.Run("bridge device removal cannot derive a protected membership diff", func(t *testing.T) { + if got := RemoveDevice(peerID.String()); got != conflictRetentionSafetyMarker { + t.Fatalf("RemoveDevice() = %q, want exact path-free safety stop", got) + } + if after := stCfg.RawCopy(); !reflect.DeepEqual(after, beforeConfig) { + t.Fatalf("blocked bridge device removal changed live config:\nbefore=%+v\nafter=%+v", beforeConfig, after) + } + if after := issue150SnapshotBridgeVault(t, folderPath); !reflect.DeepEqual(after, beforeVault) { + t.Fatalf("blocked bridge device removal changed vault:\nbefore=%+v\nafter=%+v", beforeVault, after) + } + }) +} + +func TestIssue150ProtectedBridgeCapabilitiesApplyOnlyExpectedOperationDiffs(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-capability-operations" + folderPath := filepath.Join(configDir, "protected-vault") + peerID := issue150SeedCapabilityFolderBeforeStart(t, configDir, folderID, folderPath, config.FolderTypeReceiveOnly, false) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start protected bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + + before := stCfg.RawCopy() + if got := SetFolderPaused(folderID, true); got != "" { + t.Fatalf("pause protected folder: %s", got) + } + issue150ExpectOnlyPauseChanged(t, before, stCfg.RawCopy(), folderID, true) + + before = stCfg.RawCopy() + if got := SetFolderPaused(folderID, false); got != "" { + t.Fatalf("resume protected folder: %s", got) + } + issue150ExpectOnlyPauseChanged(t, before, stCfg.RawCopy(), folderID, false) + + before = stCfg.RawCopy() + if got := ShareFolderWithDevice(folderID, peerID.String()); got != "" { + t.Fatalf("share protected folder: %s", got) + } + issue150ExpectOnlyMembershipChanged(t, before, stCfg.RawCopy(), folderID, peerID, true) + + before = stCfg.RawCopy() + if got := UnshareFolderFromDevice(folderID, peerID.String()); got != "" { + t.Fatalf("unshare protected folder: %s", got) + } + issue150ExpectOnlyMembershipChanged(t, before, stCfg.RawCopy(), folderID, peerID, false) +} + +func TestIssue150ProtectedShareCapabilityRejectsPrepareDerivedAdditionalDiffAtBridge(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-capability-derived-diff" + folderPath := filepath.Join(configDir, "protected-vault") + peerID := issue150SeedCapabilityFolderBeforeStart( + t, + configDir, + folderID, + folderPath, + config.FolderTypeReceiveOnly, + false, + ) + certificate, err := tls.LoadX509KeyPair( + filepath.Join(configDir, "cert.pem"), + filepath.Join(configDir, "key.pem"), + ) + if err != nil { + t.Fatalf("load #150 bridge identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + issue150WriteStoppedBridgeConfiguration(t, configDir, localID, func(cfg *config.Configuration) { + _, index, ok := cfg.Device(peerID) + if !ok { + t.Fatal("#150 fixture peer is missing") + } + cfg.Devices[index].IgnoredFolders = []config.ObservedFolder{{ + ID: folderID, + Label: "Issue 150 ignored offer fixture", + }} + }) + + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start protected bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + before := stCfg.RawCopy() + beforeVault := issue150SnapshotBridgeVault(t, folderPath) + + if got := ShareFolderWithDevice(folderID, peerID.String()); got != conflictRetentionSafetyMarker { + t.Fatalf("share with prepare-derived diff = %q, want exact path-free safety stop", got) + } + if after := stCfg.RawCopy(); !reflect.DeepEqual(after, before) { + t.Fatalf("blocked share changed live config:\nbefore=%+v\nafter=%+v", before, after) + } + if after := issue150SnapshotBridgeVault(t, folderPath); !reflect.DeepEqual(after, beforeVault) { + t.Fatalf("blocked share changed vault:\nbefore=%+v\nafter=%+v", beforeVault, after) + } +} + +func TestIssue150ProtectedRemoveFolderCapabilityRetainsExplicitRemovalSemantics(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-capability-remove" + folderPath := filepath.Join(configDir, "protected-vault") + issue150SeedCapabilityFolderBeforeStart(t, configDir, folderID, folderPath, config.FolderTypeReceiveOnly, false) + writeFolderMarker(t, folderPath, folderID) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start protected bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + + if got := RemoveFolder(folderID); got != "" { + t.Fatalf("remove protected folder: %s", got) + } + if _, exists := stCfg.Folders()[folderID]; exists { + t.Fatal("explicit protected RemoveFolder left the folder configured") + } + if _, err := os.Lstat(filepath.Join(folderPath, config.DefaultMarkerName)); !os.IsNotExist(err) { + t.Fatalf("explicit protected RemoveFolder did not retain marker-removal semantics: %v", err) + } +} + +func TestIssue150ProtectedLegacyRescanValueSurvivesStartupWithoutRewrite(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-protected-rescan-migration" + folderPath := filepath.Join(configDir, "protected-vault") + issue150SeedCapabilityFolderBeforeStart(t, configDir, folderID, folderPath, config.FolderTypeReceiveOnly, false) + certificate, err := tls.LoadX509KeyPair( + filepath.Join(configDir, "cert.pem"), + filepath.Join(configDir, "key.pem"), + ) + if err != nil { + t.Fatalf("load #150 bridge identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + issue150WriteStoppedBridgeConfiguration(t, configDir, localID, func(cfg *config.Configuration) { + for i := range cfg.Folders { + if cfg.Folders[i].ID == folderID { + cfg.Folders[i].RescanIntervalS = 3600 + } + } + }) + + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start protected bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + if got := stCfg.Folders()[folderID].RescanIntervalS; got != 3600 { + t.Fatalf("protected startup rewrote RescanIntervalS=%d, want preserved 3600", got) + } +} + +func TestIssue150SendOnlyBridgeOperationsRetainExistingSemantics(t *testing.T) { + configDir := testConfigDir(t) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start SendOnly bridge fixture: %s", got) + } + t.Cleanup(StopSyncthing) + + peerCertificate := issue150TestCertificate(t) + peerID := protocol.NewDeviceID(peerCertificate.Certificate[0]) + if got := AddDevice(peerID.String(), "Issue 150 peer"); got != "" { + t.Fatalf("add SendOnly peer: %s", got) + } + + const folderID = "issue150-sendonly-capability-control" + folderPath := filepath.Join(configDir, "sendonly-vault") + if got := addFolderForTesting(folderID, "Issue 150 SendOnly", folderPath); got != "" { + t.Fatalf("add SendOnly folder: %s", got) + } + for _, paused := range []bool{true, false} { + if got := SetFolderPaused(folderID, paused); got != "" { + t.Fatalf("set SendOnly paused=%t: %s", paused, got) + } + } + if got := ShareFolderWithDevice(folderID, peerID.String()); got != "" { + t.Fatalf("share SendOnly folder: %s", got) + } + if got := UnshareFolderFromDevice(folderID, peerID.String()); got != "" { + t.Fatalf("unshare SendOnly folder: %s", got) + } + if got := RemoveFolder(folderID); got != "" { + t.Fatalf("remove SendOnly folder: %s", got) + } +} + +func TestIssue150EmbeddedDatabaseLayoutStaysInsideConfiguredPrivateHome(t *testing.T) { + configDir := testConfigDir(t) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start bridge layout fixture: %s", got) + } + t.Cleanup(StopSyncthing) + + wantDatabasePath := filepath.Join(configDir, "data", "index-v2") + if got := filepath.Clean(locations.Get(locations.Database)); got != filepath.Clean(wantDatabasePath) { + t.Fatalf("database path = %q, want configured private home %q", got, wantDatabasePath) + } + if got := filepath.Clean(locations.Get(locations.ConfigFile)); got != filepath.Join(filepath.Clean(configDir), "config.xml") { + t.Fatalf("config path = %q, want configured private home", got) + } + + vaultPath := t.TempDir() + if err := os.WriteFile(filepath.Join(vaultPath, "ownership-probe.md"), []byte("ownership probe\n"), 0o600); err != nil { + t.Fatalf("write separate vault fixture: %v", err) + } + const folderID = "issue150-layout-sendonly" + if got := addFolderForTesting(folderID, "Issue 150 layout", vaultPath); got != "" { + t.Fatalf("add separate vault fixture: %s", got) + } + if got := RescanFolder(folderID); got != "" { + t.Fatalf("scan separate vault fixture: %s", got) + } + + deadline := time.Now().Add(5 * time.Second) + for { + folderDatabases, err := filepath.Glob(filepath.Join(wantDatabasePath, "folder.*.db")) + if err != nil { + t.Fatalf("glob folder databases: %v", err) + } + if len(folderDatabases) > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("folder database was not created inside the configured engine home") + } + time.Sleep(10 * time.Millisecond) + } + + entries, err := os.ReadDir(wantDatabasePath) + if err != nil { + t.Fatalf("read configured database directory: %v", err) + } + for _, entry := range entries { + name := entry.Name() + if name != "main.db" && !strings.HasPrefix(name, "folder.") && !strings.HasSuffix(name, "-wal") && !strings.HasSuffix(name, "-shm") { + continue + } + path := filepath.Join(wantDatabasePath, name) + if relative, err := filepath.Rel(configDir, path); err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + t.Fatalf("database artifact escaped configured private home: %q", name) + } + if relative, err := filepath.Rel(vaultPath, path); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + t.Fatalf("database artifact entered the security-scoped vault fixture: %q", name) + } + } +} + +func TestIssue150ConcurrentBridgeStartsMaintainSingleDatabaseOwner(t *testing.T) { + configDir := testConfigDir(t) + if got := StartSyncthing(configDir); got != "" { + t.Fatalf("start bridge owner fixture: %s", got) + } + t.Cleanup(StopSyncthing) + identity := DeviceID() + generation := EventStreamGeneration() + + const attempts = 8 + results := make(chan string, attempts) + var group sync.WaitGroup + for range attempts { + group.Go(func() { + results <- StartSyncthing(configDir) + }) + } + group.Wait() + close(results) + for result := range results { + if result != "already running" { + t.Fatalf("concurrent start = %q, want single-owner rejection", result) + } + } + if got := DeviceID(); got != identity { + t.Fatal("concurrent start changed the active engine identity") + } + if got := EventStreamGeneration(); got != generation { + t.Fatalf("concurrent start changed engine generation from %d to %d", generation, got) + } +} + +func issue150BridgeGenericModify(modify config.ModifyFunction) error { + waiter, err := stCfg.Modify(modify) + waiter.Wait() + return err +} + +func issue150ExpectCanonicalConfigurationSafetyStop(t *testing.T, err error) { + t.Helper() + if !errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { + t.Fatalf("configuration error = %v, want canonical #150 safety code", err) + } + if got := err.Error(); got != conflictRetentionSafetyMarker { + t.Fatalf("configuration error = %q, want exact path-free %q", got, conflictRetentionSafetyMarker) + } +} + +func issue150ExpectOnlyPauseChanged(t *testing.T, before, after config.Configuration, folderID string, paused bool) { + t.Helper() + folder, index, exists := after.Folder(folderID) + if !exists || folder.Paused != paused { + t.Fatalf("pause operation did not set folder %q to paused=%t", folderID, paused) + } + after.Folders[index].Paused = !paused + if !reflect.DeepEqual(after, before) { + t.Fatalf("pause capability changed more than the expected field:\nbefore=%+v\nafter-normalized=%+v", before, after) + } +} + +func issue150ExpectOnlyMembershipChanged(t *testing.T, before, after config.Configuration, folderID string, deviceID protocol.DeviceID, shared bool) { + t.Helper() + folder, index, exists := after.Folder(folderID) + if !exists || issue150FolderHasDevice(folder, deviceID) != shared { + t.Fatalf("membership operation did not set folder %q device shared=%t", folderID, shared) + } + if shared { + filtered := make([]config.FolderDeviceConfiguration, 0, len(folder.Devices)-1) + for _, device := range folder.Devices { + if device.DeviceID != deviceID { + filtered = append(filtered, device) + } + } + after.Folders[index].Devices = filtered + } else { + beforeFolder, _, ok := before.Folder(folderID) + if !ok { + t.Fatalf("membership fixture folder %q missing before operation", folderID) + } + after.Folders[index].Devices = append([]config.FolderDeviceConfiguration(nil), beforeFolder.Devices...) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("membership capability changed more than the expected device:\nbefore=%+v\nafter-normalized=%+v", before, after) + } +} + +func issue150SeedCapabilityFolderBeforeStart(t *testing.T, configDir, folderID, folderPath string, folderType config.FolderType, shared bool) protocol.DeviceID { + t.Helper() + certificate, err := tlsutil.NewCertificate( + filepath.Join(configDir, "cert.pem"), + filepath.Join(configDir, "key.pem"), + "syncthing", + 1, + false, + ) + if err != nil { + t.Fatalf("create #150 bridge identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + peerID := protocol.NewDeviceID(issue150TestCertificate(t).Certificate[0]) + raw := issue150HermeticConfig(localID) + raw.SetDevice(config.DeviceConfiguration{DeviceID: peerID, Name: "Issue 150 peer"}) + folder := raw.Defaults.Folder.Copy() + folder.ID = folderID + folder.Label = "Issue 150 protected fixture" + folder.Path = folderPath + folder.Type = folderType + folder.RescanIntervalS = defaultRescanIntervalS + folder.FSWatcherEnabled = false + folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: localID}} + if shared { + folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{DeviceID: peerID}) + } + raw.SetFolder(folder) + if err := os.MkdirAll(folderPath, 0o700); err != nil { + t.Fatalf("create #150 folder root: %v", err) + } + wrapper := config.Wrap(filepath.Join(configDir, "config.xml"), raw, localID, events.NoopLogger) + if err := wrapper.Save(); err != nil { + t.Fatalf("save #150 bridge config: %v", err) + } + return peerID +} diff --git a/go/bridge/issue150_configuration_test.go b/go/bridge/issue150_configuration_test.go new file mode 100644 index 0000000..7f1dc69 --- /dev/null +++ b/go/bridge/issue150_configuration_test.go @@ -0,0 +1,699 @@ +package bridge + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "log/slog" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/thejerf/suture/v4" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/events" + "github.com/syncthing/syncthing/lib/protocol" + "github.com/syncthing/syncthing/lib/svcutil" + "github.com/syncthing/syncthing/lib/syncthing" + "github.com/syncthing/syncthing/lib/tlsutil" +) + +func TestIssue150ReceiveReadOnlyScannerHasherLogsAreDiscardedAtBridgeBoundary(t *testing.T) { + var captured bytes.Buffer + previousSlog := slog.Default() + previousLogWriter := log.Writer() + t.Cleanup(func() { + slog.SetDefault(previousSlog) + log.SetOutput(previousLogWriter) + }) + slog.SetDefault(slog.New(slog.NewTextHandler(&captured, nil))) + log.SetOutput(&captured) + + configurePrivacySafeLogging() + const redactionProbe = "issue150-redaction-probe/scanner-hasher-note.md" + slog.Error("scanner failure", slog.String("path", redactionProbe)) + log.Printf("hasher failure: %s", redactionProbe) + if strings.Contains(captured.String(), redactionProbe) { + t.Fatalf("scanner/hasher boundary leaked a private path: %q", captured.String()) + } +} + +func TestIssue150VaultSyncStartsReceiveFoldersInGlobalReadOnlySafetyStop(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-global-receive-read-only" + folderPath := filepath.Join(configDir, "receive-root") + issue150SeedBridgeFolderBeforeStart(t, configDir, folderID, folderPath, config.FolderTypeSendReceive) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("start: %s", errMsg) + } + t.Cleanup(StopSyncthing) + + const wantStop = "vaultsync-conflict-retention-safety-stop" + if got := RescanFolder(folderID); got != wantStop { + t.Fatalf("rescan result = %q, want fixed global safety stop", got) + } + if got := SetFolderIgnores(folderID, `["issue150-redaction-probe"]`); got != wantStop { + t.Fatalf("set ignores result = %q, want fixed global safety stop", got) + } + if _, err := os.Stat(filepath.Join(folderPath, config.DefaultMarkerName)); !os.IsNotExist(err) { + t.Fatalf("folder startup created or exposed a marker in read-only mode: %v", err) + } + if _, err := os.Stat(filepath.Join(folderPath, ".stignore")); !os.IsNotExist(err) { + t.Fatalf("read-only ignore request created .stignore: %v", err) + } + + var status FolderStatus + if err := json.Unmarshal([]byte(GetFolderStatusJSON(folderID)), &status); err != nil { + t.Fatalf("decode status: %v", err) + } + if status.State != "error" || status.ErrorReason != conflictRetentionSafetyErrorReason || status.ErrorPath != "" { + t.Fatalf("global safety status is not stable and path-free: %+v", status) + } +} + +func TestIssue150ReceiveConfigurationABIsStopBeforeFilesystemOrConfigMutation(t *testing.T) { + configDir := testConfigDir(t) + const folderID = "issue150-set-path-stub" + originalPath := filepath.Join(configDir, "original") + issue150SeedBridgeFolderBeforeStart(t, configDir, folderID, originalPath, config.FolderTypeReceiveOnly) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("start: %s", errMsg) + } + t.Cleanup(StopSyncthing) + + const wantStop = "vaultsync-conflict-retention-safety-stop" + addPath := filepath.Join(configDir, "add-target") + acceptPath := filepath.Join(configDir, "accept-target") + if got := AddFolder("issue150-add-stub", "Issue 150 add", addPath); got != wantStop { + t.Fatalf("AddFolder() = %q, want fixed safety stop", got) + } + if got := AcceptPendingFolder("issue150-accept-stub", "Issue 150 accept", acceptPath, true); got != wantStop { + t.Fatalf("AcceptPendingFolder() = %q, want fixed safety stop", got) + } + for _, path := range []string{addPath, acceptPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("receive configuration ABI created a target path: %v", err) + } + } + if _, exists := stCfg.Folders()["issue150-add-stub"]; exists { + t.Fatal("AddFolder mutated config before the safety stop") + } + if _, exists := stCfg.Folders()["issue150-accept-stub"]; exists { + t.Fatal("AcceptPendingFolder mutated config before the safety stop") + } + + targetPath := filepath.Join(configDir, "target") + writeFolderMarker(t, targetPath, folderID) + if got := SetFolderPath(folderID, targetPath); got != wantStop { + t.Fatalf("SetFolderPath() = %q, want fixed safety stop", got) + } + if got := filepath.Clean(stCfg.Folders()[folderID].Path); got != filepath.Clean(originalPath) { + t.Fatalf("SetFolderPath changed receive config: got %q want %q", got, originalPath) + } + + for name, call := range map[string]func() string{ + "SetFolderPath": func() string { return SetFolderPath("issue150-unknown", targetPath) }, + "SetFolderIgnores": func() string { return SetFolderIgnores("issue150-unknown", `{not-json`) }, + "EnsureDefaultIgnores": func() string { return EnsureDefaultIgnores("issue150-unknown", `{not-json`) }, + "RescanFolder": func() string { return RescanFolder("issue150-unknown") }, + } { + if got := call(); got != wantStop { + t.Errorf("%s(unknown) = %q, want fixed safety stop", name, got) + } + } +} + +func TestIssue150GlobalReceiveCreationABIStubsAreStableForEveryStateAndInput(t *testing.T) { + StopSyncthing() + const wantStop = "vaultsync-conflict-retention-safety-stop" + inputs := []struct { + name string + call func() string + }{ + {"AddFolder empty", func() string { return AddFolder("", "", "") }}, + {"AddFolder traversal", func() string { return AddFolder("../outside", "Outside", "../../outside") }}, + {"AcceptPendingFolder empty", func() string { return AcceptPendingFolder("", "", "", false) }}, + {"AcceptPendingFolder traversal", func() string { return AcceptPendingFolder("../outside", "Outside", "../../outside", true) }}, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + if got := input.call(); got != wantStop { + t.Fatalf("stub result = %q, want fixed safety stop", got) + } + }) + } +} + +func TestIssue150SendOnlyConfigurationABIsRetainExistingSemantics(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("start: %s", errMsg) + } + t.Cleanup(StopSyncthing) + + const folderID = "issue150-send-only-configuration" + folderPath := filepath.Join(configDir, "send-only-root") + issue150ConfigureBridgeFolder(t, folderID, folderPath, config.FolderTypeSendOnly, true) + if got := SetFolderPath(folderID, folderPath); got != "" { + t.Fatalf("send-only SetFolderPath no-op = %q", got) + } + if got := SetFolderIgnores(folderID, `["*.tmp"]`); got != "" { + t.Fatalf("send-only SetFolderIgnores = %q", got) + } + if got := EnsureDefaultIgnores(folderID, `[".DS_Store"]`); got != "" { + t.Fatalf("send-only EnsureDefaultIgnores = %q", got) + } + if got := RescanFolder(folderID); got != "" { + t.Fatalf("send-only RescanFolder = %q", got) + } +} + +func TestIssue150PersistedMaxConflictsValuesSurviveSameHomeReloadWithoutRewrite(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("first start: %s", errMsg) + } + + values := []int{0, 10, -1, 23} + paths := make(map[string]string, len(values)) + for _, value := range values { + folderID := issue150ConfigurationFolderID(value) + folderPath := filepath.Join(configDir, folderID) + paths[folderID] = folderPath + issue150ConfigureBridgeFolder(t, folderID, folderPath, config.FolderTypeSendOnly, false) + issue150AssertConfiguredMaxConflicts(t, folderID, 10) + } + + waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + for index := range cfg.Folders { + for _, value := range values { + if cfg.Folders[index].ID == issue150ConfigurationFolderID(value) { + cfg.Folders[index].MaxConflicts = value + } + } + } + }) + if err != nil { + StopSyncthing() + t.Fatalf("set persisted values: %v", err) + } + waiter.Wait() + for _, value := range values { + issue150AssertConfiguredMaxConflicts(t, issue150ConfigurationFolderID(value), value) + } + identityBefore := DeviceID() + StopSyncthing() + time.Sleep(100 * time.Millisecond) + + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("same-home reload: %s", errMsg) + } + t.Cleanup(StopSyncthing) + if DeviceID() != identityBefore { + t.Fatal("device identity changed across same-home reload") + } + for _, value := range values { + folderID := issue150ConfigurationFolderID(value) + issue150AssertConfiguredMaxConflicts(t, folderID, value) + folder := stCfg.Folders()[folderID] + if filepath.Clean(folder.Path) != filepath.Clean(paths[folderID]) { + t.Fatalf("folder %s path changed across reload: got %q want %q", folderID, folder.Path, paths[folderID]) + } + } +} + +func TestIssue150GenuinePendingFolderOfferRemainsInspectionOnly(t *testing.T) { + configurePrivacySafeLogging() + if IsRunning() { + t.Fatal("bridge engine was already running") + } + + localCert := issue150TestCertificate(t) + localID := protocol.NewDeviceID(localCert.Certificate[0]) + providerCert := issue150TestCertificate(t) + providerID := protocol.NewDeviceID(providerCert.Certificate[0]) + + const folderID = "issue150-genuine-pending" + providerHome := t.TempDir() + providerFolderPath := filepath.Join(providerHome, "offered") + if err := os.MkdirAll(providerFolderPath, 0o700); err != nil { + t.Fatalf("create provider folder: %v", err) + } + providerRaw := issue150HermeticConfig(providerID) + providerRaw.Options.RawListenAddresses = []string{issue150AvailableTCPListenAddress(t)} + providerRaw.SetDevice(config.DeviceConfiguration{ + DeviceID: localID, + Addresses: []string{"dynamic"}, + }) + providerFolder := providerRaw.Defaults.Folder.Copy() + providerFolder.ID = folderID + providerFolder.Label = "Issue 150 synthetic offer" + providerFolder.Path = providerFolderPath + providerFolder.Type = config.FolderTypeSendReceive + providerFolder.FSWatcherEnabled = false + providerFolder.Devices = []config.FolderDeviceConfiguration{ + {DeviceID: providerID}, + {DeviceID: localID}, + } + providerRaw.SetFolder(providerFolder) + + _, providerEvents := issue150StartHermeticApp(t, providerHome, providerRaw, providerCert, false) + providerAddress := issue150WaitForTCPListenAddress(t, providerEvents) + + localHome := t.TempDir() + localRaw := issue150HermeticConfig(localID) + localRaw.SetDevice(config.DeviceConfiguration{ + DeviceID: providerID, + Addresses: []string{providerAddress}, + }) + local, _ := issue150StartHermeticApp(t, localHome, localRaw, localCert, true) + + issue150WaitForPendingOffer(t, local.app, folderID, providerID) + + mu.Lock() + stApp = local.app + stCfg = local.cfg + stMyID = localID + stRunning = true + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + stApp = nil + stCfg = nil + stMyID = protocol.EmptyDeviceID + stRunning = false + mu.Unlock() + }) + + acceptedPath := filepath.Join(localHome, "accepted") + const wantStop = "vaultsync-conflict-retention-safety-stop" + if errMsg := AcceptPendingFolder(folderID, "Issue 150 accepted", acceptedPath, false); errMsg != wantStop { + t.Fatalf("accept genuine pending folder = %q, want fixed safety stop", errMsg) + } + if _, ok := local.cfg.Folders()[folderID]; ok { + t.Fatal("inspection-only pending accept mutated live config") + } + if _, err := os.Stat(acceptedPath); !os.IsNotExist(err) { + t.Fatalf("inspection-only pending accept created a target: %v", err) + } + pending, err := local.app.Internals.PendingFolders(protocol.EmptyDeviceID) + if err != nil { + t.Fatalf("read pending folders after blocked accept: %v", err) + } + if _, remains := pending[folderID]; !remains { + t.Fatal("blocked pending offer disappeared instead of remaining inspectable") + } +} + +func TestIssue150ReceiveReadOnlySameHomeRestartPreservesIdentityConfigAndNeed(t *testing.T) { + configurePrivacySafeLogging() + const folderID = "issue150-same-home-need" + + localHome := t.TempDir() + localCertPath := filepath.Join(localHome, "cert.pem") + localKeyPath := filepath.Join(localHome, "key.pem") + localCert, err := tlsutil.NewCertificate(localCertPath, localKeyPath, "syncthing", 365, false) + if err != nil { + t.Fatalf("create persisted local identity: %v", err) + } + localID := protocol.NewDeviceID(localCert.Certificate[0]) + providerCert := issue150TestCertificate(t) + providerID := protocol.NewDeviceID(providerCert.Certificate[0]) + + providerHome := t.TempDir() + providerFolderPath := filepath.Join(providerHome, "source") + if err := os.MkdirAll(providerFolderPath, 0o700); err != nil { + t.Fatalf("create provider folder: %v", err) + } + if err := os.WriteFile(filepath.Join(providerFolderPath, "remote-note.md"), []byte("remote bytes\n"), 0o600); err != nil { + t.Fatalf("write provider file: %v", err) + } + providerRaw := issue150HermeticConfig(providerID) + providerRaw.Options.RawListenAddresses = []string{issue150AvailableTCPListenAddress(t)} + providerRaw.SetDevice(config.DeviceConfiguration{DeviceID: localID, Addresses: []string{"dynamic"}}) + providerFolder := providerRaw.Defaults.Folder.Copy() + providerFolder.ID = folderID + providerFolder.Label = "Issue 150 provider" + providerFolder.Path = providerFolderPath + providerFolder.Type = config.FolderTypeSendOnly + providerFolder.FSWatcherEnabled = false + providerFolder.Devices = []config.FolderDeviceConfiguration{{DeviceID: providerID}, {DeviceID: localID}} + providerRaw.SetFolder(providerFolder) + provider, providerEvents := issue150StartHermeticApp(t, providerHome, providerRaw, providerCert, false) + providerAddress := issue150WaitForTCPListenAddress(t, providerEvents) + + localFolderPath := filepath.Join(localHome, "destination") + if err := os.MkdirAll(localFolderPath, 0o700); err != nil { + t.Fatalf("create local folder: %v", err) + } + localRaw := issue150HermeticConfig(localID) + localRaw.SetDevice(config.DeviceConfiguration{DeviceID: providerID, Addresses: []string{providerAddress}}) + localFolder := localRaw.Defaults.Folder.Copy() + localFolder.ID = folderID + localFolder.Label = "Issue 150 local" + localFolder.Path = localFolderPath + localFolder.Type = config.FolderTypeSendReceive + localFolder.FSWatcherEnabled = false + localFolder.Devices = []config.FolderDeviceConfiguration{{DeviceID: localID}, {DeviceID: providerID}} + localRaw.SetFolder(localFolder) + local, _ := issue150StartHermeticApp(t, localHome, localRaw, localCert, true) + + issue150WaitForNeed(t, local.app, folderID, 1) + beforeNeed, err := local.app.Internals.NeedSize(folderID, protocol.LocalDeviceID) + if err != nil { + t.Fatalf("read need before restart: %v", err) + } + beforeConfig := local.cfg.Folders()[folderID] + if localSize, err := local.app.Internals.LocalSize(folderID); err != nil || localSize.TotalItems() != 0 { + t.Fatalf("read-only local inventory before restart = %+v, error=%v", localSize, err) + } + if _, err := os.Stat(filepath.Join(localFolderPath, config.DefaultMarkerName)); !os.IsNotExist(err) { + t.Fatalf("read-only startup created a local marker: %v", err) + } + + provider.stop(t) + local.stop(t) + reloadedCert, err := tls.LoadX509KeyPair(localCertPath, localKeyPath) + if err != nil { + t.Fatalf("reload persisted local identity: %v", err) + } + restarted := issue150RestartHermeticAppFromSameHome(t, localHome, reloadedCert) + if got := protocol.NewDeviceID(reloadedCert.Certificate[0]); got != localID { + t.Fatalf("same-home identity changed: got %s want %s", got, localID) + } + afterConfig := restarted.cfg.Folders()[folderID] + if afterConfig.ID != beforeConfig.ID || afterConfig.Path != beforeConfig.Path || afterConfig.Type != beforeConfig.Type || afterConfig.Paused != beforeConfig.Paused { + t.Fatalf("same-home folder config changed:\nbefore=%+v\nafter=%+v", beforeConfig, afterConfig) + } + afterNeed, err := restarted.app.Internals.NeedSize(folderID, protocol.LocalDeviceID) + if err != nil { + t.Fatalf("read need after restart: %v", err) + } + if afterNeed != beforeNeed || afterNeed.Files != 1 { + t.Fatalf("same-home need changed: before=%+v after=%+v", beforeNeed, afterNeed) + } + if localSize, err := restarted.app.Internals.LocalSize(folderID); err != nil || localSize.TotalItems() != 0 { + t.Fatalf("read-only local inventory after restart = %+v, error=%v", localSize, err) + } + if _, err := os.Stat(filepath.Join(localFolderPath, config.DefaultMarkerName)); !os.IsNotExist(err) { + t.Fatalf("same-home restart created a local marker: %v", err) + } +} + +type issue150HermeticApp struct { + app *syncthing.App + cfg config.Wrapper + db interface{ Close() error } + cancel context.CancelFunc + earlyDone <-chan error + stopOnce sync.Once +} + +func (app *issue150HermeticApp) stop(t *testing.T) { + t.Helper() + app.stopOnce.Do(func() { + app.app.Stop(svcutil.ExitSuccess) + if err := app.db.Close(); err != nil { + t.Errorf("close synthetic database: %v", err) + } + app.cancel() + select { + case <-app.earlyDone: + case <-time.After(5 * time.Second): + t.Error("synthetic config supervisor did not stop") + } + }) +} + +func issue150TestCertificate(t *testing.T) tls.Certificate { + t.Helper() + cert, err := tlsutil.NewCertificateInMemory("syncthing", 1) + if err != nil { + t.Fatalf("create synthetic certificate: %v", err) + } + return cert +} + +func issue150HermeticConfig(id protocol.DeviceID) config.Configuration { + cfg := config.New(id) + cfg.GUI.Enabled = false + cfg.Options.RawListenAddresses = nil + cfg.Options.GlobalAnnEnabled = false + cfg.Options.LocalAnnEnabled = false + cfg.Options.RelaysEnabled = false + cfg.Options.NATEnabled = false + cfg.Options.StartBrowser = false + cfg.Options.URAccepted = -1 + cfg.Options.CREnabled = false + cfg.Options.AutoUpgradeIntervalH = 0 + cfg.Options.MinHomeDiskFree.Value = 0 + return cfg +} + +func issue150AvailableTCPListenAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve synthetic TCP endpoint: %v", err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("release synthetic TCP endpoint: %v", err) + } + return "tcp://" + address +} + +func issue150StartHermeticApp(t *testing.T, home string, raw config.Configuration, cert tls.Certificate, receiveSideReadOnly bool) (*issue150HermeticApp, events.Subscription) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + early := suture.New("issue150-hermetic", svcutil.SpecWithDebugLogger()) + earlyDone := early.ServeBackground(ctx) + logger := events.NewLogger() + early.Add(logger) + cfg := config.Wrap(filepath.Join(home, "config.xml"), raw, protocol.NewDeviceID(cert.Certificate[0]), logger) + early.Add(cfg) + if err := cfg.Save(); err != nil { + cancel() + t.Fatalf("save synthetic config: %v", err) + } + listenEvents := logger.Subscribe(events.AllEvents) + var databaseOptions []syncthing.DatabaseOption + if receiveSideReadOnly { + databaseOptions = append(databaseOptions, syncthing.WithReceiveSideReadOnlyConfig(cfg)) + } + database, err := syncthing.OpenDatabase(filepath.Join(home, "database"), 24*time.Hour, databaseOptions...) + if err != nil { + cancel() + t.Fatalf("open synthetic database: %v", err) + } + app, err := syncthing.New(cfg, database, logger, cert, syncthing.Options{ + NoUpgrade: true, + ReceiveSideReadOnly: receiveSideReadOnly, + }) + if err != nil { + database.Close() + cancel() + t.Fatalf("create synthetic app: %v", err) + } + harness := &issue150HermeticApp{app: app, cfg: cfg, db: database, cancel: cancel, earlyDone: earlyDone} + t.Cleanup(func() { harness.stop(t) }) + if err := app.Start(); err != nil { + t.Fatalf("start synthetic app: %v", err) + } + return harness, listenEvents +} + +func issue150RestartHermeticAppFromSameHome(t *testing.T, home string, cert tls.Certificate) *issue150HermeticApp { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + early := suture.New("issue150-same-home-restart", svcutil.SpecWithDebugLogger()) + earlyDone := early.ServeBackground(ctx) + logger := events.NewLogger() + early.Add(logger) + cfg, err := syncthing.LoadConfigAtStartup(filepath.Join(home, "config.xml"), cert, logger, false, true) + if err != nil { + cancel() + t.Fatalf("load same-home config: %v", err) + } + early.Add(cfg) + database, err := syncthing.OpenDatabase( + filepath.Join(home, "database"), + 24*time.Hour, + syncthing.WithReceiveSideReadOnlyConfig(cfg), + ) + if err != nil { + cancel() + t.Fatalf("reopen same-home database: %v", err) + } + app, err := syncthing.New(cfg, database, logger, cert, syncthing.Options{ + NoUpgrade: true, + ReceiveSideReadOnly: true, + }) + if err != nil { + database.Close() + cancel() + t.Fatalf("create same-home app: %v", err) + } + harness := &issue150HermeticApp{app: app, cfg: cfg, db: database, cancel: cancel, earlyDone: earlyDone} + t.Cleanup(func() { harness.stop(t) }) + if err := app.Start(); err != nil { + t.Fatalf("start same-home app: %v", err) + } + return harness +} + +func issue150WaitForTCPListenAddress(t *testing.T, subscription events.Subscription) string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + var seen []string + for time.Now().Before(deadline) { + event, err := subscription.Poll(time.Until(deadline)) + if err != nil { + t.Fatalf("wait for synthetic TCP listener after events %v: %v", seen, err) + } + seen = append(seen, event.Type.String()) + if event.Type != events.ListenAddressesChanged { + continue + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + continue + } + addresses, ok := data["lan"].([]*url.URL) + if !ok { + continue + } + for _, address := range addresses { + if address != nil && address.Scheme == "tcp" && address.Port() != "0" { + return address.String() + } + } + } + t.Fatal("synthetic TCP listener did not publish a usable address") + return "" +} + +func issue150WaitForPendingOffer(t *testing.T, app *syncthing.App, folderID string, providerID protocol.DeviceID) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + pending, err := app.Internals.PendingFolders(protocol.EmptyDeviceID) + if err != nil { + t.Fatalf("read synthetic pending folders: %v", err) + } + if folder, ok := pending[folderID]; ok { + if _, offered := folder.OfferedBy[providerID]; !offered { + t.Fatal("pending folder was not offered by the synthetic provider") + } + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal("synthetic provider did not produce a genuine pending-folder offer") +} + +func issue150WaitForNeed(t *testing.T, app *syncthing.App, folderID string, files int) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + need, err := app.Internals.NeedSize(folderID, protocol.LocalDeviceID) + if err == nil && need.Files == files { + return + } + time.Sleep(25 * time.Millisecond) + } + need, err := app.Internals.NeedSize(folderID, protocol.LocalDeviceID) + t.Fatalf("need did not stabilize: got %+v error=%v want files=%d", need, err, files) +} + +func issue150FolderHasDevice(folder config.FolderConfiguration, id protocol.DeviceID) bool { + for _, device := range folder.Devices { + if device.DeviceID == id { + return true + } + } + return false +} + +func issue150ConfigurationFolderID(value int) string { + if value < 0 { + return "issue150-max-minus-one" + } + return fmt.Sprintf("issue150-max-%d", value) +} + +func issue150ConfigureBridgeFolder(t *testing.T, folderID, folderPath string, folderType config.FolderType, watcherEnabled bool) { + t.Helper() + if folderType != config.FolderTypeSendOnly { + t.Fatalf("live #150 fixture must be send-only, got %s", folderType) + } + folder := stCfg.RawCopy().Defaults.Folder.Copy() + folder.ID = folderID + folder.Label = "Issue 150 synthetic" + folder.Path = folderPath + folder.Type = folderType + folder.RescanIntervalS = defaultRescanIntervalS + folder.FSWatcherEnabled = watcherEnabled + folder.MaxConflicts = 10 + folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: stMyID}} + waiter, err := stCfg.Modify(func(cfg *config.Configuration) { + cfg.SetFolder(folder) + }) + if err != nil { + t.Fatalf("configure synthetic folder: %v", err) + } + waiter.Wait() +} + +func issue150SeedBridgeFolderBeforeStart(t *testing.T, configDir, folderID, folderPath string, folderType config.FolderType) { + t.Helper() + certificate, err := tlsutil.NewCertificate( + filepath.Join(configDir, "cert.pem"), + filepath.Join(configDir, "key.pem"), + "syncthing", + 1, + false, + ) + if err != nil { + t.Fatalf("create synthetic bridge identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + raw := issue150HermeticConfig(localID) + folder := raw.Defaults.Folder.Copy() + folder.ID = folderID + folder.Label = "Issue 150 synthetic" + folder.Path = folderPath + folder.Type = folderType + folder.RescanIntervalS = defaultRescanIntervalS + folder.FSWatcherEnabled = false + folder.MaxConflicts = 10 + folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: localID}} + raw.SetFolder(folder) + if err := os.MkdirAll(folderPath, 0o700); err != nil { + t.Fatalf("create synthetic folder root: %v", err) + } + wrapper := config.Wrap(filepath.Join(configDir, "config.xml"), raw, localID, events.NoopLogger) + if err := wrapper.Save(); err != nil { + t.Fatalf("save synthetic bridge config: %v", err) + } +} + +func issue150AssertConfiguredMaxConflicts(t *testing.T, folderID string, want int) { + t.Helper() + folder, exists := stCfg.Folders()[folderID] + if !exists { + t.Fatalf("folder %q missing from live config", folderID) + } + if folder.MaxConflicts != want { + t.Fatalf("folder %q MaxConflicts=%d want %d", folderID, folder.MaxConflicts, want) + } +} diff --git a/go/bridge/issue150_database_open_test.go b/go/bridge/issue150_database_open_test.go new file mode 100644 index 0000000..da06773 --- /dev/null +++ b/go/bridge/issue150_database_open_test.go @@ -0,0 +1,297 @@ +package bridge + +import ( + "bytes" + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/events" + "github.com/syncthing/syncthing/lib/locations" + "github.com/syncthing/syncthing/lib/protocol" + "github.com/syncthing/syncthing/lib/syncthing" + "github.com/syncthing/syncthing/lib/tlsutil" + _ "modernc.org/sqlite" +) + +func TestIssue150ReceiveReadOnlyBridgeOpenDatabaseRejectsPendingMigrationWithPathFreeSafetyStop(t *testing.T) { + if IsRunning() { + t.Fatal("bridge engine was already running") + } + t.Cleanup(func() { + if IsRunning() { + StopSyncthing() + } + }) + + configDir := t.TempDir() + dataDir := filepath.Join(configDir, "data") + if err := locations.SetBaseDir(locations.ConfigBaseDir, configDir); err != nil { + t.Fatalf("set synthetic config base: %v", err) + } + if err := locations.SetBaseDir(locations.DataBaseDir, dataDir); err != nil { + t.Fatalf("set synthetic data base: %v", err) + } + + certificate, err := tlsutil.NewCertificate( + locations.Get(locations.CertFile), + locations.Get(locations.KeyFile), + "syncthing", + 1, + false, + ) + if err != nil { + t.Fatalf("create synthetic identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + + raw := issue150HermeticConfig(localID) + folder := raw.Defaults.Folder.Copy() + folder.ID = "issue150-protected-pending-migration" + folder.Label = "Issue 150 protected migration fixture" + folder.Path = filepath.Join(configDir, "vault") + folder.Type = config.FolderTypeReceiveOnly + folder.Paused = true + folder.RescanIntervalS = defaultRescanIntervalS + folder.FSWatcherEnabled = false + folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: localID}} + raw.SetFolder(folder) + if err := os.MkdirAll(folder.Path, 0o700); err != nil { + t.Fatalf("create synthetic vault: %v", err) + } + wrapper := config.Wrap(locations.Get(locations.ConfigFile), raw, localID, events.NoopLogger) + if err := wrapper.Save(); err != nil { + t.Fatalf("save synthetic config: %v", err) + } + + database, err := syncthing.OpenDatabase(locations.Get(locations.Database), 24*time.Hour) + if err != nil { + t.Fatalf("seed synthetic database: %v", err) + } + if err := database.Update(folder.ID, protocol.LocalDeviceID, []protocol.FileInfo{{ + Name: "conflict-shaped-note.md", + Sequence: 1, + }}); err != nil { + database.Close() + t.Fatalf("seed protected local row: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("close seeded database: %v", err) + } + + folderDatabases, err := filepath.Glob(filepath.Join(locations.Get(locations.Database), "folder.*.db")) + if err != nil { + t.Fatalf("locate protected folder database: %v", err) + } + if len(folderDatabases) != 1 { + t.Fatalf("protected folder database count = %d, want 1", len(folderDatabases)) + } + folderDatabase := folderDatabases[0] + rawDatabase, err := sql.Open("sqlite", folderDatabase) + if err != nil { + t.Fatalf("open migration fixture: %v", err) + } + if _, err := rawDatabase.Exec(` + DELETE FROM schemamigrations; + INSERT INTO schemamigrations (schema_version, applied_at, syncthing_version) + VALUES (4, 1, 'issue150-fixture'); + `); err != nil { + rawDatabase.Close() + t.Fatalf("mark migration fixture pending: %v", err) + } + if err := rawDatabase.Close(); err != nil { + t.Fatalf("close migration fixture: %v", err) + } + + before := issue150DatabaseTreeSnapshot(t, locations.Get(locations.Database)) + if got := StartSyncthing(configDir); got != conflictRetentionSafetyMarker { + t.Fatalf("StartSyncthing() = %q, want exact path-free safety stop", got) + } + after := issue150DatabaseTreeSnapshot(t, locations.Get(locations.Database)) + if !reflect.DeepEqual(after, before) { + for name, beforeEntry := range before { + afterEntry, exists := after[name] + if !exists || beforeEntry.mode != afterEntry.mode || !bytes.Equal(afterEntry.data, beforeEntry.data) { + t.Logf("database entry %q changed: before=%#o/%d after=%#o/%d exists=%t", name, beforeEntry.mode, len(beforeEntry.data), afterEntry.mode, len(afterEntry.data), exists) + } + } + for name, afterEntry := range after { + if _, exists := before[name]; !exists { + t.Logf("database entry %q was created with mode %#o and %d bytes", name, afterEntry.mode, len(afterEntry.data)) + } + } + t.Fatal("blocked database open changed the database tree") + } +} + +func TestIssue150DatabaseSafetyStopPrecedesConfigUpgradeMutation(t *testing.T) { + if IsRunning() { + t.Fatal("bridge engine was already running") + } + t.Cleanup(func() { + if IsRunning() { + StopSyncthing() + } + }) + + configDir := t.TempDir() + dataDir := filepath.Join(configDir, "data") + if err := locations.SetBaseDir(locations.ConfigBaseDir, configDir); err != nil { + t.Fatalf("set config base: %v", err) + } + if err := locations.SetBaseDir(locations.DataBaseDir, dataDir); err != nil { + t.Fatalf("set data base: %v", err) + } + + certificate, err := tlsutil.NewCertificate( + locations.Get(locations.CertFile), + locations.Get(locations.KeyFile), + "syncthing", + 1, + false, + ) + if err != nil { + t.Fatalf("create identity: %v", err) + } + localID := protocol.NewDeviceID(certificate.Certificate[0]) + + raw := issue150HermeticConfig(localID) + folder := raw.Defaults.Folder.Copy() + folder.ID = "issue150-config-preflight-order" + folder.Label = "Issue 150 config preflight order" + folder.Path = filepath.Join(configDir, "vault") + folder.Type = config.FolderTypeReceiveOnly + folder.Paused = true + folder.FSWatcherEnabled = false + folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: localID}} + raw.SetFolder(folder) + if err := os.MkdirAll(folder.Path, 0o700); err != nil { + t.Fatalf("create vault: %v", err) + } + wrapper := config.Wrap(locations.Get(locations.ConfigFile), raw, localID, events.NoopLogger) + if err := wrapper.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + configPath := locations.Get(locations.ConfigFile) + configBytes, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + currentVersion := []byte(fmt.Sprintf(`version="%d"`, config.CurrentVersion)) + previousVersion := config.CurrentVersion - 1 + if got := bytes.Count(configBytes, currentVersion); got != 1 { + t.Fatalf("current config version occurrences = %d, want 1", got) + } + configBytes = bytes.Replace( + configBytes, + currentVersion, + []byte(fmt.Sprintf(`version="%d"`, previousVersion)), + 1, + ) + if err := os.WriteFile(configPath, configBytes, 0o600); err != nil { + t.Fatalf("write previous-version config: %v", err) + } + + database, err := syncthing.OpenDatabase(locations.Get(locations.Database), 24*time.Hour) + if err != nil { + t.Fatalf("seed database: %v", err) + } + if err := database.Update(folder.ID, protocol.LocalDeviceID, []protocol.FileInfo{{ + Name: "recognizable-note.md", + Sequence: 1, + }}); err != nil { + database.Close() + t.Fatalf("seed local row: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("close seeded database: %v", err) + } + + folderDatabases, err := filepath.Glob(filepath.Join(locations.Get(locations.Database), "folder.*.db")) + if err != nil { + t.Fatalf("locate folder database: %v", err) + } + if len(folderDatabases) != 1 { + t.Fatalf("folder database count = %d, want 1", len(folderDatabases)) + } + rawDatabase, err := sql.Open("sqlite", folderDatabases[0]) + if err != nil { + t.Fatalf("open database fixture: %v", err) + } + if _, err := rawDatabase.Exec(` + DELETE FROM schemamigrations; + INSERT INTO schemamigrations (schema_version, applied_at, syncthing_version) + VALUES (4, 1, 'issue150-fixture'); + `); err != nil { + rawDatabase.Close() + t.Fatalf("mark migration pending: %v", err) + } + if err := rawDatabase.Close(); err != nil { + t.Fatalf("close database fixture: %v", err) + } + + archivePath := configPath + fmt.Sprintf(".v%d", previousVersion) + if _, err := os.Stat(archivePath); !os.IsNotExist(err) { + t.Fatalf("config archive exists before startup: %v", err) + } + databaseBefore := issue150DatabaseTreeSnapshot(t, locations.Get(locations.Database)) + if got := StartSyncthing(configDir); got != conflictRetentionSafetyMarker { + t.Fatalf("StartSyncthing() = %q, want exact path-free safety stop", got) + } + configAfter, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config after safety stop: %v", err) + } + if !bytes.Equal(configAfter, configBytes) { + t.Fatal("database safety stop rewrote the previous-version config") + } + if _, err := os.Stat(archivePath); !os.IsNotExist(err) { + t.Fatalf("database safety stop created a config archive: %v", err) + } + databaseAfter := issue150DatabaseTreeSnapshot(t, locations.Get(locations.Database)) + if !reflect.DeepEqual(databaseAfter, databaseBefore) { + t.Fatal("database safety stop changed the database tree") + } +} + +type issue150DatabaseTreeEntry struct { + mode os.FileMode + data []byte +} + +func issue150DatabaseTreeSnapshot(t *testing.T, databasePath string) map[string]issue150DatabaseTreeEntry { + t.Helper() + snapshot := make(map[string]issue150DatabaseTreeEntry) + err := filepath.Walk(databasePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == databasePath { + return nil + } + relative, err := filepath.Rel(databasePath, path) + if err != nil { + return err + } + entry := issue150DatabaseTreeEntry{mode: info.Mode()} + if info.Mode().IsRegular() { + contents, err := os.ReadFile(path) + if err != nil { + return err + } + entry.data = bytes.Clone(contents) + } + snapshot[relative] = entry + return nil + }) + if err != nil { + t.Fatalf("snapshot database tree: %v", err) + } + return snapshot +} diff --git a/go/bridge/pendingfolders.go b/go/bridge/pendingfolders.go index 134a936..398c19d 100644 --- a/go/bridge/pendingfolders.go +++ b/go/bridge/pendingfolders.go @@ -83,20 +83,20 @@ func GetPendingFoldersJSON() string { return string(data) } -// AcceptPendingFolder creates a new SendReceive folder with the given ID and -// path, and shares it with all devices that offered it. This is the counterpart -// to a remote device sharing a folder — the user picks a local directory and -// the folder is configured to sync with the offering peers. -// -// allowNonEmpty must be false unless the user explicitly confirmed syncing -// into an existing directory that already holds content: accepting into a -// non-empty target merges two content sets and pushes the mix to every -// offering peer (#54). The floor treats a directory holding at most -// Obsidian's `.obsidian` configuration folder as empty — mirror of the Swift -// `VaultManager.isEmptyVaultListing` rule; the two layers must decide -// emptiness identically or a share refused above would slip through below. -// Returns empty string on success, error message on failure. +// AcceptPendingFolder retains the pre-2.0.2 ABI but does not accept an offer. +// It returns the stable, path-free receive-side safety error without inspecting +// its arguments or mutating filesystem or configuration state (#150). func AcceptPendingFolder(folderID, label, path string, allowNonEmpty bool) string { + // Pending offers become SendReceive folders. The 2.0.2 receive-side + // read-only runtime keeps the ABI but refuses before inspecting the offer, + // target path, filesystem, or config (#150). + return conflictRetentionSafetyMarker +} + +// acceptPendingFolderForTesting retains the pre-2.0.2 validation core for +// focused bridge tests. Its fixture is send-only so tests cannot bypass the +// receive-side hard floor. Production code must use the exported stub above. +func acceptPendingFolderForTesting(folderID, label, path string, allowNonEmpty bool) string { mu.Lock() defer mu.Unlock() @@ -175,7 +175,7 @@ func AcceptPendingFolder(folderID, label, path string, allowNonEmpty bool) strin ID: folderID, Label: label, Path: path, - Type: config.FolderTypeSendReceive, + Type: config.FolderTypeSendOnly, RescanIntervalS: defaultRescanIntervalS, FSWatcherEnabled: true, FSWatcherDelayS: 10, diff --git a/go/bridge/pendingfolders_test.go b/go/bridge/pendingfolders_test.go index e43bbaf..669e195 100644 --- a/go/bridge/pendingfolders_test.go +++ b/go/bridge/pendingfolders_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/syncthing/syncthing/lib/config" ) func TestGetPendingFoldersJSONNotRunning(t *testing.T) { @@ -36,11 +38,33 @@ func TestGetPendingFoldersJSONEmpty(t *testing.T) { func TestAcceptPendingFolderNotRunning(t *testing.T) { // Should fail when not running. - if errMsg := AcceptPendingFolder("test", "Test", "/tmp/test", false); errMsg != "syncthing not running" { + targetPath := filepath.Join(t.TempDir(), "target") + if errMsg := acceptPendingFolderForTesting("test", "Test", targetPath, false); errMsg != "syncthing not running" { t.Fatalf("AcceptPendingFolder when stopped = %q, want 'syncthing not running'", errMsg) } } +func TestIssue150PendingValidationHelperCannotCreateReceiveCapableFolder(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + t.Cleanup(StopSyncthing) + + const folderID = "issue150-pending-sendonly-fixture" + targetPath := filepath.Join(configDir, "pending-sendonly-fixture") + if errMsg := acceptPendingFolderForTesting(folderID, "Issue 150 pending fixture", targetPath, false); errMsg != "" { + t.Fatalf("pending validation fixture failed: %s", errMsg) + } + folder, exists := stCfg.Folders()[folderID] + if !exists { + t.Fatal("pending validation fixture did not create a folder") + } + if folder.Type != config.FolderTypeSendOnly { + t.Fatalf("pending validation fixture type = %s, want sendonly", folder.Type) + } +} + func TestAcceptPendingFolderEmptyID(t *testing.T) { configDir := testConfigDir(t) @@ -50,7 +74,8 @@ func TestAcceptPendingFolderEmptyID(t *testing.T) { defer StopSyncthing() // Empty folder ID should fail. - if errMsg := AcceptPendingFolder("", "Test", "/tmp/test", false); errMsg != "folder ID is required" { + targetPath := filepath.Join(t.TempDir(), "target") + if errMsg := acceptPendingFolderForTesting("", "Test", targetPath, false); errMsg != "folder ID is required" { t.Fatalf("AcceptPendingFolder empty ID = %q, want 'folder ID is required'", errMsg) } } @@ -65,13 +90,13 @@ func TestAcceptPendingFolderDuplicate(t *testing.T) { // Add a folder first. folderPath := filepath.Join(configDir, "existing") - if errMsg := AddFolder("existing", "Existing", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("existing", "Existing", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } // Accepting a folder with the same ID should fail. acceptPath := filepath.Join(configDir, "accept") - if errMsg := AcceptPendingFolder("existing", "Dup", acceptPath, false); errMsg != "folder already exists" { + if errMsg := acceptPendingFolderForTesting("existing", "Dup", acceptPath, false); errMsg != "folder already exists" { t.Fatalf("AcceptPendingFolder duplicate = %q, want 'folder already exists'", errMsg) } } @@ -86,7 +111,7 @@ func TestAcceptPendingFolderPathCollision(t *testing.T) { // Configure a first folder at a local path. vaultPath := filepath.Join(configDir, "VaultA") - if errMsg := AddFolder("vault-a", "Vault A", vaultPath); errMsg != "" { + if errMsg := addFolderForTesting("vault-a", "Vault A", vaultPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -95,23 +120,23 @@ func TestAcceptPendingFolderPathCollision(t *testing.T) { // A second, distinct folder ID targeting the SAME path must be rejected — // otherwise the two vaults merge into one directory and propagate the mix // back to both peers (issue #45). - if errMsg := AcceptPendingFolder("vault-b", "Vault B", vaultPath, false); errMsg != wantCollision { + if errMsg := acceptPendingFolderForTesting("vault-b", "Vault B", vaultPath, false); errMsg != wantCollision { t.Fatalf("AcceptPendingFolder same path = %q, want %q", errMsg, wantCollision) } // Trailing-slash and case variants resolve to the same directory and must // be rejected too (cleaned + case-insensitive comparison). - if errMsg := AcceptPendingFolder("vault-c", "Vault C", vaultPath+"/", false); errMsg != wantCollision { + if errMsg := acceptPendingFolderForTesting("vault-c", "Vault C", vaultPath+"/", false); errMsg != wantCollision { t.Fatalf("AcceptPendingFolder trailing-slash variant = %q, want %q", errMsg, wantCollision) } caseVariant := filepath.Join(configDir, "vaulta") - if errMsg := AcceptPendingFolder("vault-d", "Vault D", caseVariant, false); errMsg != wantCollision { + if errMsg := acceptPendingFolderForTesting("vault-d", "Vault D", caseVariant, false); errMsg != wantCollision { t.Fatalf("AcceptPendingFolder case variant = %q, want %q", errMsg, wantCollision) } // A genuinely distinct path is still accepted. otherPath := filepath.Join(configDir, "VaultB") - if errMsg := AcceptPendingFolder("vault-e", "Vault E", otherPath, false); errMsg != "" { + if errMsg := acceptPendingFolderForTesting("vault-e", "Vault E", otherPath, false); errMsg != "" { t.Fatalf("AcceptPendingFolder distinct path = %q, want success", errMsg) } } @@ -126,7 +151,7 @@ func TestAcceptPendingFolderNestedPathCollision(t *testing.T) { // Configure a first folder — a vault that owns its directory. vaultPath := filepath.Join(configDir, "Workshops") - if errMsg := AddFolder("vault-workshops", "Workshops", vaultPath); errMsg != "" { + if errMsg := addFolderForTesting("vault-workshops", "Workshops", vaultPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -138,31 +163,31 @@ func TestAcceptPendingFolderNestedPathCollision(t *testing.T) { // to its peers — the #45 merge one level down (the vault-as-root setup // from the #45 follow-up report). nested := filepath.Join(vaultPath, "Obsidian-Vault-Life") - if errMsg := AcceptPendingFolder("vault-life", "Life", nested, false); errMsg != wantInside { + if errMsg := acceptPendingFolderForTesting("vault-life", "Life", nested, false); errMsg != wantInside { t.Fatalf("AcceptPendingFolder nested path = %q, want %q", errMsg, wantInside) } // Deeper nesting and case variants resolve into the same subtree and must // be rejected too (case-folding APFS). deep := filepath.Join(vaultPath, "notes", "sub") - if errMsg := AcceptPendingFolder("vault-deep", "Deep", deep, false); errMsg != wantInside { + if errMsg := acceptPendingFolderForTesting("vault-deep", "Deep", deep, false); errMsg != wantInside { t.Fatalf("AcceptPendingFolder deeply nested path = %q, want %q", errMsg, wantInside) } caseVariant := filepath.Join(configDir, "workshops", "Nested") - if errMsg := AcceptPendingFolder("vault-case", "Case", caseVariant, false); errMsg != wantInside { + if errMsg := acceptPendingFolderForTesting("vault-case", "Case", caseVariant, false); errMsg != wantInside { t.Fatalf("AcceptPendingFolder case-variant nested path = %q, want %q", errMsg, wantInside) } // A share that would CONTAIN an existing folder is the same overlap from // the other side and must be rejected as well. - if errMsg := AcceptPendingFolder("vault-parent", "Parent", configDir, false); errMsg != wantContains { + if errMsg := acceptPendingFolderForTesting("vault-parent", "Parent", configDir, false); errMsg != wantContains { t.Fatalf("AcceptPendingFolder containing path = %q, want %q", errMsg, wantContains) } // A sibling whose name merely starts with the existing folder's name is // NOT nested (boundary-aware comparison) and is accepted. sibling := filepath.Join(configDir, "WorkshopsArchive") - if errMsg := AcceptPendingFolder("vault-sibling", "Sibling", sibling, false); errMsg != "" { + if errMsg := acceptPendingFolderForTesting("vault-sibling", "Sibling", sibling, false); errMsg != "" { t.Fatalf("AcceptPendingFolder name-prefix sibling = %q, want success", errMsg) } } @@ -234,13 +259,13 @@ func TestAcceptPendingFolderNonEmptyTarget(t *testing.T) { if err := os.WriteFile(filepath.Join(nonEmpty, "note.md"), []byte("x"), 0o600); err != nil { t.Fatalf("write: %v", err) } - if errMsg := AcceptPendingFolder("vault-nonempty", "Existing Notes", nonEmpty, false); errMsg != wantRefused { + if errMsg := acceptPendingFolderForTesting("vault-nonempty", "Existing Notes", nonEmpty, false); errMsg != wantRefused { t.Fatalf("AcceptPendingFolder non-empty unconfirmed = %q, want %q", errMsg, wantRefused) } // The user's explicit confirmation travels through allowNonEmpty and // lets the same accept proceed (remove + re-accept recovery, 006). - if errMsg := AcceptPendingFolder("vault-nonempty", "Existing Notes", nonEmpty, true); errMsg != "" { + if errMsg := acceptPendingFolderForTesting("vault-nonempty", "Existing Notes", nonEmpty, true); errMsg != "" { t.Fatalf("AcceptPendingFolder non-empty confirmed = %q, want success", errMsg) } @@ -250,7 +275,7 @@ func TestAcceptPendingFolderNonEmptyTarget(t *testing.T) { if err := os.MkdirAll(filepath.Join(emptyVault, ".obsidian"), 0o700); err != nil { t.Fatalf("mkdir: %v", err) } - if errMsg := AcceptPendingFolder("vault-emptyvault", "Fresh Vault", emptyVault, false); errMsg != "" { + if errMsg := acceptPendingFolderForTesting("vault-emptyvault", "Fresh Vault", emptyVault, false); errMsg != "" { t.Fatalf("AcceptPendingFolder empty vault unconfirmed = %q, want success", errMsg) } @@ -267,7 +292,7 @@ func TestAcceptPendingFolderNonEmptyTarget(t *testing.T) { t.Fatalf("chmod: %v", err) } defer os.Chmod(unreadable, 0o700) - errMsg := AcceptPendingFolder("vault-unreadable", "Unreadable", unreadable, false) + errMsg := acceptPendingFolderForTesting("vault-unreadable", "Unreadable", unreadable, false) if !strings.HasPrefix(errMsg, "read folder path:") { t.Fatalf("AcceptPendingFolder unreadable = %q, want 'read folder path:' prefix", errMsg) } @@ -286,7 +311,7 @@ func TestAcceptPendingFolderCreatesPath(t *testing.T) { // path creation and config mutation without requiring a real remote // device offer. folderPath := filepath.Join(configDir, "accepted-vault") - if errMsg := AcceptPendingFolder("vault-1", "My Vault", folderPath, false); errMsg != "" { + if errMsg := acceptPendingFolderForTesting("vault-1", "My Vault", folderPath, false); errMsg != "" { t.Fatalf("AcceptPendingFolder failed: %s", errMsg) } diff --git a/go/bridge/rescan_migration_test.go b/go/bridge/rescan_migration_test.go index e650853..53330d3 100644 --- a/go/bridge/rescan_migration_test.go +++ b/go/bridge/rescan_migration_test.go @@ -17,7 +17,7 @@ func TestAddFolder_DefaultsTo60sRescan(t *testing.T) { defer StopSyncthing() folderPath := filepath.Join(configDir, "rescan-default") - if errMsg := AddFolder("rescan-default", "Rescan Default", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("rescan-default", "Rescan Default", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -38,7 +38,7 @@ func TestStart_MigratesLegacy3600(t *testing.T) { } folderPath := filepath.Join(configDir, "legacy-folder") - if errMsg := AddFolder("legacy", "Legacy", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("legacy", "Legacy", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } @@ -81,7 +81,7 @@ func TestStart_PreservesCustomInterval(t *testing.T) { } folderPath := filepath.Join(configDir, "custom-folder") - if errMsg := AddFolder("custom", "Custom", folderPath); errMsg != "" { + if errMsg := addFolderForTesting("custom", "Custom", folderPath); errMsg != "" { t.Fatalf("AddFolder failed: %s", errMsg) } diff --git a/go/bridge/status.go b/go/bridge/status.go index dd5db6e..eedfe61 100644 --- a/go/bridge/status.go +++ b/go/bridge/status.go @@ -38,6 +38,16 @@ var ( const maxBridgeEventsPerPoll = 120 +const ( + conflictRetentionSafetyMarker = "vaultsync-conflict-retention-safety-stop" + conflictRetentionSafetyErrorReason = "conflict_retention_safety_stop" + conflictRetentionSafetyErrorMessage = "VaultSync stopped an automatic conflict change before local data or index state was modified." + folderErrorEvidenceUnavailableReason = "folder_error_evidence_unavailable" + folderErrorEvidenceUnavailableMessage = "Folder safety evidence is unavailable." + folderCompletionEvidenceUnavailableReason = "folder_completion_evidence_unavailable" + folderCompletionEvidenceUnavailableMessage = "Folder completion evidence is unavailable." +) + // GetConnectionsJSON returns a JSON array of all device connections with status. func GetConnectionsJSON() string { mu.Lock() @@ -198,15 +208,25 @@ func bridgeEventData(ev events.Event) map[string]interface{} { setStringField(out, "from", data["from"]) setStringField(out, "to", data["to"]) if errMsg, ok := stringFromAny(data["error"]); ok && strings.TrimSpace(errMsg) != "" { - out["error"] = errMsg + if isConflictRetentionSafetyError(errMsg) { + setConflictRetentionSafetyEvent(out) + } else { + out["error"] = errMsg + } } case events.ItemFinished: setStringField(out, "folder", data["folder"]) - setStringField(out, "item", data["item"]) setStringField(out, "type", data["type"]) setStringField(out, "action", data["action"]) if errMsg, ok := stringFromAny(data["error"]); ok && strings.TrimSpace(errMsg) != "" { - out["error"] = errMsg + if isConflictRetentionSafetyError(errMsg) { + setConflictRetentionSafetyEvent(out) + } else { + setStringField(out, "item", data["item"]) + out["error"] = errMsg + } + } else { + setStringField(out, "item", data["item"]) } case events.DeviceConnected: setStringField(out, "id", data["id"]) @@ -220,7 +240,9 @@ func bridgeEventData(ev events.Event) map[string]interface{} { case events.FolderErrors: setStringField(out, "folder", data["folder"]) entries := parseFolderErrorEntries(data["errors"]) - if len(entries) > 0 { + if hasConflictRetentionSafetyEntry(entries) { + setConflictRetentionSafetyEvent(out) + } else if len(entries) > 0 { first := entries[0] if first.Error != "" { out["message"] = first.Error @@ -235,6 +257,15 @@ func bridgeEventData(ev events.Event) map[string]interface{} { return out } +func setConflictRetentionSafetyEvent(out map[string]interface{}) { + for key := range out { + delete(out, key) + } + out["reason"] = conflictRetentionSafetyErrorReason + out["message"] = conflictRetentionSafetyErrorMessage + out["error"] = conflictRetentionSafetyErrorMessage +} + func setStringField(out map[string]interface{}, key string, raw interface{}) { if val, ok := stringFromAny(raw); ok { out[key] = val @@ -351,6 +382,13 @@ func folderErrorFromEvent(ev events.Event) (string, folderErrorDetail, bool) { if len(entries) == 0 { return "", folderErrorDetail{}, false } + if hasConflictRetentionSafetyEntry(entries) { + return folderID, folderErrorDetail{ + Reason: conflictRetentionSafetyErrorReason, + Message: conflictRetentionSafetyErrorMessage, + Changed: ev.Time.Format("2006-01-02T15:04:05Z07:00"), + }, true + } first := entries[0] if first.Error == "" { @@ -388,6 +426,9 @@ func parseFolderErrorEntries(raw interface{}) []folderErrorEntry { } func classifyFolderErrorReason(message string) string { + if isConflictRetentionSafetyError(message) { + return conflictRetentionSafetyErrorReason + } msg := strings.ToLower(message) switch { case strings.Contains(msg, "permission denied"), @@ -412,3 +453,16 @@ func classifyFolderErrorReason(message string) string { return "unknown_error" } } + +func hasConflictRetentionSafetyEntry(entries []folderErrorEntry) bool { + for _, entry := range entries { + if isConflictRetentionSafetyError(entry.Error) { + return true + } + } + return false +} + +func isConflictRetentionSafetyError(message string) bool { + return strings.TrimSpace(message) == conflictRetentionSafetyMarker +} diff --git a/go/bridge/syncthing.go b/go/bridge/syncthing.go index 6acd444..0f96838 100644 --- a/go/bridge/syncthing.go +++ b/go/bridge/syncthing.go @@ -5,6 +5,7 @@ package bridge import ( "context" "crypto/tls" + "errors" "fmt" "io" "log" @@ -96,16 +97,57 @@ func StartSyncthing(configDir string) string { stEvLogger = events.NewLogger() earlySvc.Add(stEvLogger) - // Load existing config or create a default one. + // Load existing config or create a default one. A protected database must + // pass its read-only preflight before an older config is archived or saved. // skipPortProbing=true because iOS doesn't need port probing. - stCfg, err = syncthing.LoadConfigAtStartup( + var closeDatabase func() error + var newAppWithDatabase func(syncthing.Options) (*syncthing.App, error) + var retainDatabase func() + var databaseOpenErr error + stCfg, err = syncthing.LoadConfigAtStartupWithPreflight( locations.Get(locations.ConfigFile), stCert, stEvLogger, false, true, + func(cfg config.Wrapper) error { + if err := config.EnableVaultSyncReceiveSideProtection(cfg); err != nil { + return err + } + sdb, err := syncthing.OpenDatabase( + locations.Get(locations.Database), + 24*time.Hour, + syncthing.WithReceiveSideReadOnlyConfig(cfg), + ) + if err != nil { + databaseOpenErr = err + return err + } + closeDatabase = sdb.Close + newAppWithDatabase = func(opts syncthing.Options) (*syncthing.App, error) { + return syncthing.New(cfg, sdb, stEvLogger, stCert, opts) + } + retainDatabase = func() { + stDB = sdb + } + return nil + }, ) if err != nil { + if closeDatabase != nil { + _ = closeDatabase() + } cancel() + if errors.Is(err, syncthing.ErrReceiveSideReadOnlySafetyStop) { + return conflictRetentionSafetyMarker + } + if databaseOpenErr != nil { + return fmt.Sprintf("database: %v", databaseOpenErr) + } return fmt.Sprintf("config: %v", err) } + if closeDatabase == nil || newAppWithDatabase == nil || retainDatabase == nil { + cancel() + return conflictRetentionSafetyMarker + } + earlySvc.Add(stCfg) // Configure for embedded iOS use. @@ -121,7 +163,11 @@ func StartSyncthing(configDir string) string { cfg.Options.NATEnabled = true }) if err != nil { + _ = closeDatabase() cancel() + if errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { + return conflictRetentionSafetyMarker + } return fmt.Sprintf("configure: %v", err) } waiter.Wait() @@ -132,46 +178,47 @@ func StartSyncthing(configDir string) string { // values are preserved. waiter, err = stCfg.Modify(func(cfg *config.Configuration) { for i := range cfg.Folders { - if cfg.Folders[i].RescanIntervalS == 3600 { + if cfg.Folders[i].Type == config.FolderTypeSendOnly && cfg.Folders[i].RescanIntervalS == 3600 { cfg.Folders[i].RescanIntervalS = defaultRescanIntervalS } } }) if err != nil { + _ = closeDatabase() cancel() + if errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { + return conflictRetentionSafetyMarker + } return fmt.Sprintf("migrate rescan interval: %v", err) } waiter.Wait() - // Open database. - sdb, err := syncthing.OpenDatabase( - locations.Get(locations.Database), - 24*time.Hour, - ) - if err != nil { - cancel() - return fmt.Sprintf("database: %v", err) - } - // Create and start Syncthing. opts := syncthing.Options{ - NoUpgrade: true, + NoUpgrade: true, + ReceiveSideReadOnly: true, } - stApp, err = syncthing.New(stCfg, sdb, stEvLogger, stCert, opts) + stApp, err = newAppWithDatabase(opts) if err != nil { - sdb.Close() + _ = closeDatabase() cancel() + if errors.Is(err, syncthing.ErrReceiveSideReadOnlySafetyStop) { + return conflictRetentionSafetyMarker + } return fmt.Sprintf("create app: %v", err) } if err := stApp.Start(); err != nil { - sdb.Close() + _ = closeDatabase() cancel() stApp = nil + if errors.Is(err, syncthing.ErrReceiveSideReadOnlySafetyStop) { + return conflictRetentionSafetyMarker + } return fmt.Sprintf("start: %v", err) } - stDB = sdb + retainDatabase() // Create a buffered event subscription for the bridge. sub := stEvLogger.Subscribe(events.AllEvents) diff --git a/go/patches/README.md b/go/patches/README.md index dc807b0..ac8059c 100644 --- a/go/patches/README.md +++ b/go/patches/README.md @@ -39,6 +39,14 @@ Two safety nets compensate: rampup is otherwise clamped to 5s rounds while the discovery cache is still empty) and TCP dial timeout 10s→5s (a stale cached LAN address must fail over to the relay path quickly). iOS-specific tuning, not for upstreaming. +- `syncthing/004-issue-150-loss-aware-conflict-retention.patch` — the temporary + 2.0.2 receive-side read-only policy for issues #150/#167. Receive-capable + folders stop before local file/index mutation while authenticated remote + indexes can still persist Need. Canonical database-name and integrity + preflight, exact one-shot configuration capabilities, inert runtime runners, + inspection-only versioning, restart behavior, and privacy-safe runtime logs + are covered by the patch's `TestIssue150...` regressions. Send Only retains + its existing behavior. - `go-stun/001-nil-safe-host-methods.patch` — nil-safe host methods. ## Before each release diff --git a/go/patches/syncthing/004-issue-150-loss-aware-conflict-retention.patch b/go/patches/syncthing/004-issue-150-loss-aware-conflict-retention.patch new file mode 100644 index 0000000..d4b84c7 --- /dev/null +++ b/go/patches/syncthing/004-issue-150-loss-aware-conflict-retention.patch @@ -0,0 +1,5440 @@ +diff --git a/internal/db/sqlite/basedb.go b/internal/db/sqlite/basedb.go +index 2058fde..0c10bc3 100644 +--- a/internal/db/sqlite/basedb.go ++++ b/internal/db/sqlite/basedb.go +@@ -61,6 +61,12 @@ func openBase(path string, maxConns int, pragmas, schemaScripts, migrationScript + if err != nil { + return nil, wrap(err) + } ++ closeOnError := true ++ defer func() { ++ if closeOnError { ++ _ = sqlDB.Close() ++ } ++ }() + + sqlDB.SetMaxOpenConns(maxConns) + +@@ -186,6 +192,7 @@ func openBase(path string, maxConns int, pragmas, schemaScripts, migrationScript + } + } + ++ closeOnError = false + return db, nil + } + +diff --git a/internal/db/sqlite/db_folderdb.go b/internal/db/sqlite/db_folderdb.go +index 2431f75..e311641 100644 +--- a/internal/db/sqlite/db_folderdb.go ++++ b/internal/db/sqlite/db_folderdb.go +@@ -41,6 +41,9 @@ func (s *DB) getFolderDB(folder string, create bool) (*folderDB, error) { + SELECT database_name FROM folders + WHERE folder_id = ? + `).Get(&dbns, folder); err != nil && !errors.Is(err, sql.ErrNoRows) { ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + return nil, wrap(err) + } + +@@ -68,24 +71,42 @@ func (s *DB) getFolderDB(folder string, create bool) (*folderDB, error) { + + idx, err := s.folderIdxLocked(folder) + if err != nil { ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + return nil, wrap(err) + } + + // The database name is the folder index ID and a random slug. + slug := strings.ToLower(rand.String(8)) + dbName = fmt.Sprintf("folder.%04x-%s.db", idx, slug) ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ if !validFolderDatabaseName(idx, dbName) || !s.receiveSideFutureDatabaseNameAvailable(dbName) { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } ++ } + if _, err := s.stmt(`UPDATE folders SET database_name = ? WHERE idx = ?`).Exec(dbName, idx); err != nil { ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + return nil, wrap(err, "set name") + } + } + +- slog.Debug("Folder database opened", "folder", folder, "db", dbName) ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ slog.Debug("Protected folder database opened", "folder", folder) ++ } else { ++ slog.Debug("Folder database opened", "folder", folder, "db", dbName) ++ } + path := dbName + if !filepath.IsAbs(path) { + path = filepath.Join(s.pathBase, dbName) + } + fdb, err := s.folderDBOpener(folder, path, s.deleteRetention) + if err != nil { ++ if len(s.receiveSideReadOnlyFolders) > 0 { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + return nil, wrap(err) + } + s.folderDBs[folder] = fdb +diff --git a/internal/db/sqlite/db_open.go b/internal/db/sqlite/db_open.go +index dcce431..0467a86 100644 +--- a/internal/db/sqlite/db_open.go ++++ b/internal/db/sqlite/db_open.go +@@ -17,6 +17,7 @@ import ( + "github.com/syncthing/syncthing/internal/db" + "github.com/syncthing/syncthing/internal/slogutil" + "github.com/syncthing/syncthing/lib/build" ++ "github.com/syncthing/syncthing/lib/config" + ) + + const ( +@@ -33,6 +34,8 @@ type DB struct { + folderDBsMut sync.RWMutex + folderDBs map[string]*folderDB + folderDBOpener func(folder, path string, deleteRetention time.Duration) (*folderDB, error) ++ ++ receiveSideReadOnlyFolders map[string]struct{} + } + + var _ db.DB = (*DB)(nil) +@@ -49,6 +52,20 @@ func WithDeleteRetention(d time.Duration) Option { + } + } + ++// WithReceiveSideReadOnlyFolders enables the VaultSync database preflight ++// whenever the configuration contains at least one protected folder. ++func WithReceiveSideReadOnlyFolders(folderIDs []string) Option { ++ return func(s *DB) { ++ if len(folderIDs) == 0 { ++ return ++ } ++ s.receiveSideReadOnlyFolders = make(map[string]struct{}, len(folderIDs)) ++ for _, folderID := range folderIDs { ++ s.receiveSideReadOnlyFolders[folderID] = struct{}{} ++ } ++ } ++} ++ + func Open(path string, opts ...Option) (*DB, error) { + pragmas := []string{ + "journal_mode = WAL", +@@ -65,18 +82,8 @@ func Open(path string, opts ...Option) (*DB, error) { + "sql/migrations/main/*", + } + +- _ = os.MkdirAll(path, 0o700) +- initTmpDir(path) +- +- mainPath := filepath.Join(path, "main.db") +- mainBase, err := openBase(mainPath, maxDBConns, pragmas, schemas, migrations) +- if err != nil { +- return nil, err +- } +- + db := &DB{ + pathBase: path, +- baseDB: mainBase, + folderDBs: make(map[string]*folderDB), + folderDBOpener: openFolderDB, + } +@@ -85,11 +92,37 @@ func Open(path string, opts ...Option) (*DB, error) { + opt(db) + } + +- if err := db.cleanDroppedFolders(); err != nil { +- slog.Warn("Failed to clean dropped folders", slogutil.Error(err)) ++ protected := len(db.receiveSideReadOnlyFolders) > 0 ++ if protected { ++ if err := preflightReceiveSideReadOnlyDatabase(path, db.receiveSideReadOnlyFolders); err != nil { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } ++ } ++ ++ _ = os.MkdirAll(path, 0o700) ++ initTmpDir(path) ++ ++ mainPath := filepath.Join(path, "main.db") ++ mainBase, err := openBase(mainPath, maxDBConns, pragmas, schemas, migrations) ++ if err != nil { ++ if protected { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } ++ return nil, err ++ } ++ db.baseDB = mainBase ++ ++ if !protected { ++ if err := db.cleanDroppedFolders(); err != nil { ++ slog.Warn("Failed to clean dropped folders", slogutil.Error(err)) ++ } + } + + if err := db.startFolderDatabases(); err != nil { ++ _ = db.Close() ++ if protected { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + return nil, wrap(err) + } + +diff --git a/internal/db/sqlite/issue150_database_name_test.go b/internal/db/sqlite/issue150_database_name_test.go +new file mode 100644 +index 0000000..ceac295 +--- /dev/null ++++ b/internal/db/sqlite/issue150_database_name_test.go +@@ -0,0 +1,70 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package sqlite ++ ++import ( ++ "errors" ++ "os" ++ "path/filepath" ++ "testing" ++ ++ "github.com/syncthing/syncthing/lib/config" ++) ++ ++func TestIssue150ReceiveSideDatabaseOptionStopsBeforePathCreation(t *testing.T) { ++ databasePath := filepath.Join(t.TempDir(), "missing-database") ++ database, err := Open(databasePath, WithReceiveSideReadOnlyFolders([]string{""})) ++ if database != nil { ++ _ = database.Close() ++ t.Fatal("database opened with invalid protected-folder policy") ++ } ++ if !errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { ++ t.Fatalf("database error = %v, want canonical safety stop", err) ++ } ++ if _, err := os.Lstat(databasePath); !os.IsNotExist(err) { ++ t.Fatalf("database path was created before policy preflight: %v", err) ++ } ++} ++ ++func TestIssue150FolderDatabaseNameValidationPreservesHistoricalCanonicalASCII(t *testing.T) { ++ testCases := []struct { ++ name string ++ idx int64 ++ dbName string ++ accepted bool ++ }{ ++ {name: "Issue150/minimum-index-width", idx: 1, dbName: "folder.0001-abcdefgh.db", accepted: true}, ++ {name: "Issue150/lowercase-hex-index", idx: 0xabcd, dbName: "folder.abcd-2345679a.db", accepted: true}, ++ {name: "Issue150/index-wider-than-four-digits", idx: 0x10000, dbName: "folder.10000-zyxwvuts.db", accepted: true}, ++ {name: "Issue150/absolute", idx: 1, dbName: "/outside/folder.0001-abcdefgh.db"}, ++ {name: "Issue150/parent-traversal", idx: 1, dbName: "../folder.0001-abcdefgh.db"}, ++ {name: "Issue150/uppercase-prefix", idx: 1, dbName: "Folder.0001-abcdefgh.db"}, ++ {name: "Issue150/uppercase-index", idx: 0xabcd, dbName: "folder.ABCD-abcdefgh.db"}, ++ {name: "Issue150/uppercase-slug", idx: 1, dbName: "folder.0001-ABCDEFGH.db"}, ++ {name: "Issue150/non-ascii-slug", idx: 1, dbName: "folder.0001-abcdefgé.db"}, ++ {name: "Issue150/wrong-row-index", idx: 2, dbName: "folder.0001-abcdefgh.db"}, ++ {name: "Issue150/short-index", idx: 1, dbName: "folder.001-abcdefgh.db"}, ++ {name: "Issue150/short-slug", idx: 1, dbName: "folder.0001-abcdefg.db"}, ++ {name: "Issue150/long-slug", idx: 1, dbName: "folder.0001-abcdefghi.db"}, ++ {name: "Issue150/non-historical-zero", idx: 1, dbName: "folder.0001-abcdefg0.db"}, ++ {name: "Issue150/non-historical-one", idx: 1, dbName: "folder.0001-abcdefg1.db"}, ++ {name: "Issue150/non-historical-eight", idx: 1, dbName: "folder.0001-abcdefg8.db"}, ++ {name: "Issue150/path-separator", idx: 1, dbName: "folder.0001-abcd/efgh.db"}, ++ {name: "Issue150/extra-extension", idx: 1, dbName: "folder.0001-abcdefgh.db.copy"}, ++ {name: "Issue150/wal-sidecar", idx: 1, dbName: "folder.0001-abcdefgh.db-wal"}, ++ {name: "Issue150/shm-sidecar", idx: 1, dbName: "folder.0001-abcdefgh.db-shm"}, ++ {name: "Issue150/main-database", idx: 1, dbName: "main.db"}, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ if got := validFolderDatabaseName(testCase.idx, testCase.dbName); got != testCase.accepted { ++ t.Fatalf("validFolderDatabaseName() = %t, want %t", got, testCase.accepted) ++ } ++ }) ++ } ++} +diff --git a/internal/db/sqlite/vaultsync_preflight.go b/internal/db/sqlite/vaultsync_preflight.go +new file mode 100644 +index 0000000..7f44596 +--- /dev/null ++++ b/internal/db/sqlite/vaultsync_preflight.go +@@ -0,0 +1,359 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package sqlite ++ ++import ( ++ "context" ++ "database/sql" ++ "errors" ++ "fmt" ++ "net/url" ++ "os" ++ "path/filepath" ++ "strconv" ++ "strings" ++) ++ ++var errReceiveSideReadOnlyDatabasePreflight = errors.New("receive-side database safety preflight failed") ++ ++type databaseArtifact struct { ++ name string ++ info os.FileInfo ++} ++ ++type registeredFolderDatabase struct { ++ idx int64 ++ folderID string ++ databaseName sql.NullString ++} ++ ++func preflightReceiveSideReadOnlyDatabase(path string, protectedFolders map[string]struct{}) error { ++ for folderID := range protectedFolders { ++ if folderID == "" { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ } ++ ++ artifacts, rootExists, err := receiveSideDatabaseArtifacts(path) ++ if err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if !rootExists { ++ return nil ++ } ++ ++ mainArtifact, mainExists := artifacts["main.db"] ++ if !mainExists { ++ if len(artifacts) != 0 { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ return nil ++ } ++ ++ var registrations []registeredFolderDatabase ++ if err := validateReceiveSideReadOnlyDatabase( ++ filepath.Join(path, mainArtifact.name), ++ applicationIDMain, ++ func(database *sql.DB) error { ++ rows, err := database.Query(` ++ SELECT idx, folder_id, database_name ++ FROM folders ++ ORDER BY idx ++ `) ++ if err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ defer rows.Close() ++ for rows.Next() { ++ var registration registeredFolderDatabase ++ if err := rows.Scan(®istration.idx, ®istration.folderID, ®istration.databaseName); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ registrations = append(registrations, registration) ++ } ++ if err := rows.Err(); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ return nil ++ }, ++ ); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ ++ registeredNames := make(map[string]struct{}, len(registrations)) ++ for _, registration := range registrations { ++ if !registration.databaseName.Valid { ++ continue ++ } ++ name := registration.databaseName.String ++ if !validFolderDatabaseName(registration.idx, name) { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ folded := strings.ToLower(name) ++ if _, exists := registeredNames[folded]; exists { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ registeredNames[folded] = struct{}{} ++ } ++ ++ // Validate every registered database before opening the first one. This ++ // keeps row and policy ordering from selecting a mutable partial prefix. ++ for _, registration := range registrations { ++ if !registration.databaseName.Valid { ++ continue ++ } ++ name := registration.databaseName.String ++ artifact, exists := artifacts[name] ++ if !exists { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if err := validateReceiveSideReadOnlyDatabase( ++ filepath.Join(path, artifact.name), ++ applicationIDFolder, ++ func(database *sql.DB) error { ++ var folderID []byte ++ if err := database.QueryRow("SELECT value FROM kv WHERE key = 'folderID'").Scan(&folderID); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if string(folderID) != registration.folderID { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ return nil ++ }, ++ ); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ } ++ ++ return nil ++} ++ ++func receiveSideDatabaseArtifacts(path string) (map[string]databaseArtifact, bool, error) { ++ rootInfo, err := os.Lstat(path) ++ if os.IsNotExist(err) { ++ return nil, false, nil ++ } ++ if err != nil || rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ ++ entries, err := os.ReadDir(path) ++ if err != nil { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ artifacts := make(map[string]databaseArtifact) ++ for _, entry := range entries { ++ name := entry.Name() ++ if !isReceiveSideDatabaseArtifact(name) { ++ continue ++ } ++ if !validReceiveSideDatabaseArtifactName(name) { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ info, err := os.Lstat(filepath.Join(path, name)) ++ if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ artifacts[name] = databaseArtifact{name: name, info: info} ++ } ++ ++ artifactList := make([]databaseArtifact, 0, len(artifacts)) ++ for _, artifact := range artifacts { ++ artifactList = append(artifactList, artifact) ++ } ++ for idx, first := range artifactList { ++ for _, second := range artifactList[idx+1:] { ++ if strings.EqualFold(first.name, second.name) || os.SameFile(first.info, second.info) { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ } ++ } ++ // The immutable SQL view below must not validate a base file while ++ // committed logical state remains in a journal. Applying or checkpointing ++ // that state would mutate the database before validation. ++ for name := range artifacts { ++ if receiveSideDatabaseArtifactIsJournal(name) { ++ return nil, true, errReceiveSideReadOnlyDatabasePreflight ++ } ++ } ++ ++ return artifacts, true, nil ++} ++ ++func isReceiveSideDatabaseArtifact(name string) bool { ++ folded := strings.ToLower(name) ++ return folded == "main.db" || folded == "main.db-wal" || folded == "main.db-shm" || folded == "main.db-journal" || strings.HasPrefix(folded, "folder.") ++} ++ ++func validReceiveSideDatabaseArtifactName(name string) bool { ++ switch name { ++ case "main.db", "main.db-wal", "main.db-shm", "main.db-journal": ++ return true ++ } ++ ++ base := name ++ if strings.HasSuffix(base, "-wal") { ++ base = strings.TrimSuffix(base, "-wal") ++ } else if strings.HasSuffix(base, "-shm") { ++ base = strings.TrimSuffix(base, "-shm") ++ } else if strings.HasSuffix(base, "-journal") { ++ base = strings.TrimSuffix(base, "-journal") ++ } ++ if !strings.HasPrefix(base, "folder.") { ++ return false ++ } ++ indexEnd := strings.IndexByte(strings.TrimPrefix(base, "folder."), '-') ++ if indexEnd < 0 { ++ return false ++ } ++ indexText := strings.TrimPrefix(base, "folder.")[:indexEnd] ++ idx, err := strconv.ParseInt(indexText, 16, 64) ++ if err != nil { ++ return false ++ } ++ return validFolderDatabaseName(idx, base) ++} ++ ++func receiveSideDatabaseArtifactIsJournal(name string) bool { ++ return strings.HasSuffix(name, "-wal") || strings.HasSuffix(name, "-shm") || strings.HasSuffix(name, "-journal") ++} ++ ++func validFolderDatabaseName(idx int64, name string) bool { ++ if idx < 1 { ++ return false ++ } ++ prefix := fmt.Sprintf("folder.%04x-", idx) ++ if !strings.HasPrefix(name, prefix) { ++ return false ++ } ++ suffix := strings.TrimPrefix(name, prefix) ++ if len(suffix) != 11 || suffix[8:] != ".db" { ++ return false ++ } ++ for _, character := range suffix[:8] { ++ if character >= 'a' && character <= 'z' { ++ continue ++ } ++ switch character { ++ case '2', '3', '4', '5', '6', '7', '9': ++ continue ++ default: ++ return false ++ } ++ } ++ return true ++} ++ ++func (s *DB) receiveSideFutureDatabaseNameAvailable(name string) bool { ++ var registered []sql.NullString ++ if err := s.stmt("SELECT database_name FROM folders").Select(®istered); err != nil { ++ return false ++ } ++ for _, existing := range registered { ++ if existing.Valid && strings.EqualFold(existing.String, name) { ++ return false ++ } ++ } ++ ++ entries, err := os.ReadDir(s.pathBase) ++ if err != nil { ++ return false ++ } ++ for _, entry := range entries { ++ if strings.EqualFold(entry.Name(), name) { ++ return false ++ } ++ } ++ return true ++} ++ ++func validateReceiveSideReadOnlyDatabase(path string, expectedApplicationID int, validate func(*sql.DB) error) (resultErr error) { ++ pathURL := url.URL{ ++ Scheme: "file", ++ Path: fileToUriPath(path), ++ RawQuery: "immutable=1&mode=ro", ++ } ++ database, err := sql.Open(dbDriver, pathURL.String()) ++ if err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ database.SetMaxOpenConns(1) ++ defer func() { ++ if err := database.Close(); err != nil && resultErr == nil { ++ resultErr = errReceiveSideReadOnlyDatabasePreflight ++ } ++ }() ++ ++ ctx := context.Background() ++ if _, err := database.ExecContext(ctx, "PRAGMA query_only = ON"); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ var queryOnly int ++ if err := database.QueryRowContext(ctx, "PRAGMA query_only").Scan(&queryOnly); err != nil || queryOnly != 1 { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ var applicationID int ++ if err := database.QueryRowContext(ctx, "PRAGMA application_id").Scan(&applicationID); err != nil || applicationID != expectedApplicationID { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ var schemaVersion int ++ if err := database.QueryRowContext(ctx, ` ++ SELECT schema_version ++ FROM schemamigrations ++ ORDER BY schema_version DESC ++ LIMIT 1 ++ `).Scan(&schemaVersion); err != nil || schemaVersion != currentSchemaVersion { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if err := validateReceiveSideDatabaseQuickCheck(ctx, database); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if err := validateReceiveSideDatabaseForeignKeys(ctx, database); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if validate != nil { ++ if err := validate(database); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ } ++ return nil ++} ++ ++func validateReceiveSideDatabaseQuickCheck(ctx context.Context, database *sql.DB) error { ++ rows, err := database.QueryContext(ctx, "PRAGMA quick_check") ++ if err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ defer rows.Close() ++ results := 0 ++ for rows.Next() { ++ var result string ++ if err := rows.Scan(&result); err != nil || result != "ok" { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ results++ ++ } ++ if err := rows.Err(); err != nil || results != 1 { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ return nil ++} ++ ++func validateReceiveSideDatabaseForeignKeys(ctx context.Context, database *sql.DB) error { ++ rows, err := database.QueryContext(ctx, "PRAGMA foreign_key_check") ++ if err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ defer rows.Close() ++ if rows.Next() { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ if err := rows.Err(); err != nil { ++ return errReceiveSideReadOnlyDatabasePreflight ++ } ++ return nil ++} +diff --git a/lib/config/issue150_capability_test.go b/lib/config/issue150_capability_test.go +new file mode 100644 +index 0000000..ac84438 +--- /dev/null ++++ b/lib/config/issue150_capability_test.go +@@ -0,0 +1,736 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package config ++ ++import ( ++ "errors" ++ "reflect" ++ "sync/atomic" ++ "testing" ++ ++ "github.com/syncthing/syncthing/lib/events" ++ "github.com/syncthing/syncthing/lib/protocol" ++) ++ ++const issue150ConfigSafetyStop = "vaultsync-conflict-retention-safety-stop" ++ ++const ( ++ issue150ProtectedA = "issue150-protected-a" ++ issue150ProtectedN = "issue150-protected-new" ++ issue150ProtectedZ = "issue150-protected-z" ++ issue150SendOnlyN = "issue150-send-only-new" ++ issue150SendOnly = "issue150-send-only" ++) ++ ++type issue150SubscriberSpy struct { ++ verifyCalls atomic.Int32 ++ commitCalls atomic.Int32 ++} ++ ++func (s *issue150SubscriberSpy) VerifyConfiguration(_, _ Configuration) error { ++ s.verifyCalls.Add(1) ++ return nil ++} ++ ++func (s *issue150SubscriberSpy) CommitConfiguration(_, _ Configuration) bool { ++ s.commitCalls.Add(1) ++ return true ++} ++ ++func (*issue150SubscriberSpy) String() string { ++ return "issue150SubscriberSpy" ++} ++ ++func TestIssue150GenericProtectedConfigurationDiffsStopBeforeSubscribers(t *testing.T) { ++ tests := []struct { ++ name string ++ modify func(*Configuration) ++ remove bool ++ }{ ++ { ++ name: "path", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Path = "/issue150/fixture/changed" ++ }) ++ }, ++ }, ++ { ++ name: "device extension", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }, ++ }, ++ { ++ name: "remove through generic wrapper API", ++ remove: true, ++ }, ++ { ++ name: "type", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Type = FolderTypeSendOnly ++ }) ++ }, ++ }, ++ { ++ name: "add protected folder", ++ modify: func(cfg *Configuration) { ++ cfg.SetFolder(issue150Folder(*cfg, issue150ProtectedN, FolderTypeSendReceive, "/issue150/fixture/new")) ++ }, ++ }, ++ { ++ name: "convert send-only to protected", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150SendOnly, func(folder *FolderConfiguration) { ++ folder.Type = FolderTypeReceiveOnly ++ }) ++ }, ++ }, ++ { ++ name: "filesystem", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.FilesystemType = FilesystemTypeFake ++ }) ++ }, ++ }, ++ { ++ name: "ignore behavior", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.IgnoreDelete = !folder.IgnoreDelete ++ folder.IgnorePerms = !folder.IgnorePerms ++ }) ++ }, ++ }, ++ { ++ name: "rescan", ++ modify: func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.RescanIntervalS++ ++ folder.FSWatcherEnabled = !folder.FSWatcherEnabled ++ }) ++ }, ++ }, ++ } ++ ++ for _, test := range tests { ++ t.Run(test.name, func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ ++ var err error ++ if test.remove { ++ var waiter Waiter ++ waiter, err = wrapper.RemoveFolder(issue150ProtectedA) ++ waiter.Wait() ++ } else { ++ err = issue150Modify(wrapper, test.modify) ++ } ++ ++ issue150ExpectSafetyStop(t, err) ++ if after := wrapper.RawCopy(); !reflect.DeepEqual(after, before) { ++ t.Fatalf("rejected generic diff changed config:\nbefore=%+v\nafter=%+v", before, after) ++ } ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ } ++} ++ ++func TestIssue150ExactProtectedCapabilitiesAuthorizeOnlyTheirBoundDiffs(t *testing.T) { ++ t.Run("remove first protected folder", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncRemoveFolderCapability(issue150ProtectedA) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150RemoveFolder(cfg, issue150ProtectedA) ++ }) ++ if err != nil { ++ t.Fatalf("exact remove capability failed: %v", err) ++ } ++ expected := before.Copy() ++ issue150RemoveFolder(&expected, issue150ProtectedA) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ t.Run("remove last protected folder", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncRemoveFolderCapability(issue150ProtectedZ) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150RemoveFolder(cfg, issue150ProtectedZ) ++ }) ++ if err != nil { ++ t.Fatalf("exact remove capability failed: %v", err) ++ } ++ expected := before.Copy() ++ issue150RemoveFolder(&expected, issue150ProtectedZ) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ for _, paused := range []bool{true, false} { ++ name := "pause" ++ if !paused { ++ name = "resume" ++ } ++ t.Run(name, func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = !paused ++ }) ++ }) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, paused) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = paused ++ }) ++ }) ++ if err != nil { ++ t.Fatalf("exact pause capability failed: %v", err) ++ } ++ expected := before.Copy() ++ issue150MutateFolder(&expected, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = paused ++ }) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ } ++ ++ t.Run("share exact device", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncShareFolderCapability(issue150ProtectedA, device2) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }) ++ if err != nil { ++ t.Fatalf("exact share capability failed: %v", err) ++ } ++ expected := before.Copy() ++ issue150MutateFolder(&expected, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ if err := expected.prepare(device1); err != nil { ++ t.Fatalf("prepare expected share config: %v", err) ++ } ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ t.Run("unshare exact device", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncUnshareFolderCapability(issue150ProtectedA, device2) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150UnshareFolder(cfg, issue150ProtectedA, device2) ++ }) ++ if err != nil { ++ t.Fatalf("exact unshare capability failed: %v", err) ++ } ++ expected := before.Copy() ++ issue150UnshareFolder(&expected, issue150ProtectedA, device2) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++} ++ ++func TestIssue150ProtectedCapabilitiesAreOneShotAndNonTransferable(t *testing.T) { ++ t.Run("reuse", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ setPaused := func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ } ++ if err := issue150ModifyWithCapability(wrapper, capability, setPaused); err != nil { ++ t.Fatalf("first capability use failed: %v", err) ++ } ++ beforeReuse := wrapper.RawCopy() ++ issue150ExpectSafetyStop(t, issue150ModifyWithCapability(wrapper, capability, setPaused)) ++ issue150ExpectConfiguration(t, wrapper, beforeReuse) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ t.Run("wrong folder", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedZ, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ ++ t.Run("wrong device", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncShareFolderCapability(issue150ProtectedA, device2) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device3}) ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ ++ t.Run("wrong operation", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncRemoveFolderCapability(issue150ProtectedA) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++} ++ ++func TestIssue150ZeroValueCapabilityFailsClosed(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ var callbackCalls atomic.Int32 ++ ++ err := issue150ModifyWithCapability(wrapper, VaultSyncConfigCapability{}, func(*Configuration) { ++ callbackCalls.Add(1) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ if got := callbackCalls.Load(); got != 0 { ++ t.Fatalf("zero-value capability invoked callback %d times, want zero", got) ++ } ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++} ++ ++func TestIssue150NoOpCapabilityUseIsConsumed(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ ++ issue150ExpectSafetyStop(t, issue150ModifyWithCapability(wrapper, capability, func(*Configuration) {})) ++ issue150ExpectSafetyStop(t, issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ })) ++ ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++} ++ ++func TestIssue150ProtectionIsOptInAndCapabilityRequiresEnabledWrapper(t *testing.T) { ++ cfg := New(device1) ++ cfg.SetFolder(issue150Folder(cfg, issue150ProtectedA, FolderTypeReceiveOnly, "/issue150/fixture/a")) ++ if err := cfg.prepare(device1); err != nil { ++ t.Fatalf("prepare #150 opt-in fixture: %v", err) ++ } ++ ++ wrapper := startWrapper(Wrap("", cfg, device1, events.NoopLogger)) ++ t.Cleanup(wrapper.stop) ++ spy := new(issue150SubscriberSpy) ++ wrapper.Subscribe(spy) ++ before := wrapper.RawCopy() ++ var callbackCalls atomic.Int32 ++ ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ callbackCalls.Add(1) ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ if got := callbackCalls.Load(); got != 0 { ++ t.Fatalf("capability on disabled protection invoked callback %d times, want zero", got) ++ } ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ ++ err = issue150Modify(wrapper, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Path = "/issue150/fixture/opt-in-disabled" ++ }) ++ }) ++ if err != nil { ++ t.Fatalf("unprotected wrapper changed existing generic semantics: %v", err) ++ } ++ expected := before.Copy() ++ issue150MutateFolder(&expected, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Path = "/issue150/fixture/opt-in-disabled" ++ }) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++} ++ ++func TestIssue150ConcurrentCapabilityReuseAllowsExactlyOneQueuedUse(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ setPaused := func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ } ++ ++ start := make(chan struct{}) ++ results := make(chan error, 2) ++ for range 2 { ++ go func() { ++ <-start ++ results <- issue150ModifyWithCapability(wrapper, capability, setPaused) ++ }() ++ } ++ close(start) ++ ++ successes := 0 ++ safetyStops := 0 ++ for range 2 { ++ err := <-results ++ if err == nil { ++ successes++ ++ continue ++ } ++ issue150ExpectSafetyStop(t, err) ++ safetyStops++ ++ } ++ if successes != 1 || safetyStops != 1 { ++ t.Fatalf("concurrent capability results: successes=%d safetyStops=%d, want exactly one each", successes, safetyStops) ++ } ++ ++ expected := before.Copy() ++ issue150MutateFolder(&expected, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++} ++ ++func TestIssue150ProtectedCapabilitiesRejectAdditionalOrReorderedDiffs(t *testing.T) { ++ t.Run("additional folder field", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ folder.Path = "/issue150/fixture/additional" ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ ++ t.Run("additional global field", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncSetFolderPausedCapability(issue150ProtectedA, true) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Paused = true ++ }) ++ cfg.Options.LocalAnnEnabled = !cfg.Options.LocalAnnEnabled ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ ++ t.Run("share plus device reorder", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncShareFolderCapability(issue150ProtectedA, device2) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ folder.Devices[0], folder.Devices[1] = folder.Devices[1], folder.Devices[0] ++ }) ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ ++ t.Run("remove plus folder reorder", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncRemoveFolderCapability(issue150ProtectedA) ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150RemoveFolder(cfg, issue150ProtectedA) ++ cfg.Folders[0], cfg.Folders[1] = cfg.Folders[1], cfg.Folders[0] ++ }) ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++} ++ ++func TestIssue150ProtectedShareCapabilityRejectsPrepareDerivedAdditionalDiff(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, func(cfg *Configuration) { ++ _, index, ok := cfg.Device(device2) ++ if !ok { ++ t.Fatal("#150 fixture peer is missing") ++ } ++ cfg.Devices[index].IgnoredFolders = []ObservedFolder{{ ++ ID: issue150ProtectedA, ++ Label: "Issue 150 ignored offer", ++ }} ++ }) ++ before := wrapper.RawCopy() ++ capability := NewVaultSyncShareFolderCapability(issue150ProtectedA, device2) ++ ++ err := issue150ModifyWithCapability(wrapper, capability, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }) ++ ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++} ++ ++func TestIssue150GenericProtectedRemovalStopsInEitherFolderOrder(t *testing.T) { ++ for _, folderID := range []string{issue150ProtectedA, issue150ProtectedZ} { ++ t.Run(folderID, func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ waiter, err := wrapper.RemoveFolder(folderID) ++ waiter.Wait() ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++ }) ++ } ++} ++ ++func TestIssue150GenericDeviceRemovalCannotDeriveProtectedMembershipDiff(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150ProtectedA, func(folder *FolderConfiguration) { ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }) ++ before := wrapper.RawCopy() ++ ++ waiter, err := wrapper.RemoveDevice(device2) ++ waiter.Wait() ++ issue150ExpectSafetyStop(t, err) ++ issue150ExpectConfiguration(t, wrapper, before) ++ issue150ExpectSubscriberCalls(t, spy, 0, 0) ++} ++ ++func TestIssue150SendOnlyGenericConfigurationRetainsExistingSemantics(t *testing.T) { ++ t.Run("add alongside protected folders", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ err := issue150Modify(wrapper, func(cfg *Configuration) { ++ cfg.SetFolder(issue150Folder(*cfg, issue150SendOnlyN, FolderTypeSendOnly, "/issue150/send-only-new")) ++ }) ++ if err != nil { ++ t.Fatalf("new SendOnly folder was rejected: %v", err) ++ } ++ expected := before.Copy() ++ expected.SetFolder(issue150Folder(expected, issue150SendOnlyN, FolderTypeSendOnly, "/issue150/send-only-new")) ++ if err := expected.prepare(device1); err != nil { ++ t.Fatalf("prepare expected SendOnly config: %v", err) ++ } ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ t.Run("path filesystem ignores rescan and device", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ err := issue150Modify(wrapper, func(cfg *Configuration) { ++ issue150MutateFolder(cfg, issue150SendOnly, func(folder *FolderConfiguration) { ++ folder.Path = "/issue150/send-only/changed" ++ folder.FilesystemType = FilesystemTypeFake ++ folder.IgnoreDelete = !folder.IgnoreDelete ++ folder.IgnorePerms = !folder.IgnorePerms ++ folder.RescanIntervalS++ ++ folder.FSWatcherEnabled = !folder.FSWatcherEnabled ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ }) ++ if err != nil { ++ t.Fatalf("SendOnly generic diff was rejected: %v", err) ++ } ++ expected := before.Copy() ++ issue150MutateFolder(&expected, issue150SendOnly, func(folder *FolderConfiguration) { ++ folder.Path = "/issue150/send-only/changed" ++ folder.FilesystemType = FilesystemTypeFake ++ folder.IgnoreDelete = !folder.IgnoreDelete ++ folder.IgnorePerms = !folder.IgnorePerms ++ folder.RescanIntervalS++ ++ folder.FSWatcherEnabled = !folder.FSWatcherEnabled ++ folder.Devices = append(folder.Devices, FolderDeviceConfiguration{DeviceID: device2}) ++ }) ++ if err := expected.prepare(device1); err != nil { ++ t.Fatalf("prepare expected SendOnly config: %v", err) ++ } ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++ ++ t.Run("remove", func(t *testing.T) { ++ wrapper, spy := issue150ProtectedWrapper(t, nil) ++ before := wrapper.RawCopy() ++ waiter, err := wrapper.RemoveFolder(issue150SendOnly) ++ waiter.Wait() ++ if err != nil { ++ t.Fatalf("SendOnly generic remove was rejected: %v", err) ++ } ++ expected := before.Copy() ++ issue150RemoveFolder(&expected, issue150SendOnly) ++ issue150ExpectConfiguration(t, wrapper, expected) ++ issue150ExpectSubscriberCalls(t, spy, 1, 1) ++ }) ++} ++ ++func issue150ProtectedWrapper(t *testing.T, adjust func(*Configuration)) (*testWrapper, *issue150SubscriberSpy) { ++ t.Helper() ++ cfg := New(device1) ++ cfg.SetDevice(DeviceConfiguration{DeviceID: device2, Name: "Issue 150 peer two"}) ++ cfg.SetDevice(DeviceConfiguration{DeviceID: device3, Name: "Issue 150 peer three"}) ++ ++ for _, folder := range []FolderConfiguration{ ++ issue150Folder(cfg, issue150ProtectedZ, FolderTypeReceiveEncrypted, "/issue150/fixture/z"), ++ issue150Folder(cfg, issue150SendOnly, FolderTypeSendOnly, "/issue150/send-only"), ++ issue150Folder(cfg, issue150ProtectedA, FolderTypeReceiveOnly, "/issue150/fixture/a"), ++ } { ++ cfg.SetFolder(folder) ++ } ++ if adjust != nil { ++ adjust(&cfg) ++ } ++ if err := cfg.prepare(device1); err != nil { ++ t.Fatalf("prepare #150 config fixture: %v", err) ++ } ++ ++ base := Wrap("", cfg, device1, events.NoopLogger) ++ if err := EnableVaultSyncReceiveSideProtection(base); err != nil { ++ t.Fatalf("enable #150 receive-side config protection: %v", err) ++ } ++ wrapper := startWrapper(base) ++ t.Cleanup(wrapper.stop) ++ spy := new(issue150SubscriberSpy) ++ wrapper.Subscribe(spy) ++ return wrapper, spy ++} ++ ++func issue150Folder(cfg Configuration, id string, folderType FolderType, path string) FolderConfiguration { ++ folder := cfg.Defaults.Folder.Copy() ++ folder.ID = id ++ folder.Label = "Issue 150 fixture" ++ folder.Path = path ++ folder.Type = folderType ++ folder.Devices = []FolderDeviceConfiguration{{DeviceID: device1}} ++ return folder ++} ++ ++func issue150Modify(wrapper Wrapper, modify ModifyFunction) error { ++ waiter, err := wrapper.Modify(modify) ++ waiter.Wait() ++ return err ++} ++ ++func issue150ModifyWithCapability(wrapper Wrapper, capability VaultSyncConfigCapability, modify ModifyFunction) error { ++ if testWrapper, ok := wrapper.(*testWrapper); ok { ++ wrapper = testWrapper.Wrapper ++ } ++ waiter, err := ModifyWithVaultSyncCapability(wrapper, capability, modify) ++ waiter.Wait() ++ return err ++} ++ ++func issue150MutateFolder(cfg *Configuration, folderID string, modify func(*FolderConfiguration)) { ++ for index := range cfg.Folders { ++ if cfg.Folders[index].ID == folderID { ++ modify(&cfg.Folders[index]) ++ return ++ } ++ } ++} ++ ++func issue150RemoveFolder(cfg *Configuration, folderID string) { ++ filtered := make([]FolderConfiguration, 0, len(cfg.Folders)-1) ++ for _, folder := range cfg.Folders { ++ if folder.ID != folderID { ++ filtered = append(filtered, folder) ++ } ++ } ++ cfg.Folders = filtered ++} ++ ++func issue150UnshareFolder(cfg *Configuration, folderID string, deviceID protocol.DeviceID) { ++ issue150MutateFolder(cfg, folderID, func(folder *FolderConfiguration) { ++ filtered := make([]FolderDeviceConfiguration, 0, len(folder.Devices)-1) ++ for _, device := range folder.Devices { ++ if device.DeviceID != deviceID { ++ filtered = append(filtered, device) ++ } ++ } ++ folder.Devices = filtered ++ }) ++} ++ ++func issue150ExpectSafetyStop(t *testing.T, err error) { ++ t.Helper() ++ if err == nil { ++ t.Fatal("configuration mutation succeeded, want #150 safety stop") ++ } ++ if !errors.Is(err, ErrVaultSyncReceiveSideSafetyStop) { ++ t.Fatalf("configuration error %q does not preserve the #150 safety code", err) ++ } ++ if got := err.Error(); got != issue150ConfigSafetyStop { ++ t.Fatalf("configuration error = %q, want exact path-free %q", got, issue150ConfigSafetyStop) ++ } ++} ++ ++func issue150ExpectConfiguration(t *testing.T, wrapper Wrapper, expected Configuration) { ++ t.Helper() ++ if got := wrapper.RawCopy(); !reflect.DeepEqual(got, expected) { ++ t.Fatalf("configuration mismatch:\n got=%+v\nwant=%+v", got, expected) ++ } ++} ++ ++func issue150ExpectSubscriberCalls(t *testing.T, spy *issue150SubscriberSpy, verify, commit int32) { ++ t.Helper() ++ if got := spy.verifyCalls.Load(); got != verify { ++ t.Fatalf("subscriber VerifyConfiguration calls = %d, want %d", got, verify) ++ } ++ if got := spy.commitCalls.Load(); got != commit { ++ t.Fatalf("subscriber CommitConfiguration calls = %d, want %d", got, commit) ++ } ++} +diff --git a/lib/config/vaultsync_capability.go b/lib/config/vaultsync_capability.go +new file mode 100644 +index 0000000..013f729 +--- /dev/null ++++ b/lib/config/vaultsync_capability.go +@@ -0,0 +1,263 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package config ++ ++import ( ++ "errors" ++ "reflect" ++ "slices" ++ "sync/atomic" ++ ++ "github.com/syncthing/syncthing/lib/protocol" ++) ++ ++var ErrVaultSyncReceiveSideSafetyStop = errors.New("vaultsync-conflict-retention-safety-stop") ++ ++type vaultSyncConfigOperation uint8 ++ ++const ( ++ vaultSyncConfigOperationRemoveFolder vaultSyncConfigOperation = iota + 1 ++ vaultSyncConfigOperationSetFolderPaused ++ vaultSyncConfigOperationShareFolder ++ vaultSyncConfigOperationUnshareFolder ++) ++ ++// VaultSyncConfigCapability authorizes one exact protected-folder config ++// transition. Copies share the same one-shot state. ++type VaultSyncConfigCapability struct { ++ state *vaultSyncConfigCapabilityState ++} ++ ++type vaultSyncConfigCapabilityState struct { ++ consumed atomic.Bool ++ operation vaultSyncConfigOperation ++ folderID string ++ paused bool ++ deviceID protocol.DeviceID ++} ++ ++func NewVaultSyncRemoveFolderCapability(folderID string) VaultSyncConfigCapability { ++ return newVaultSyncConfigCapability(vaultSyncConfigOperationRemoveFolder, folderID, false, protocol.EmptyDeviceID) ++} ++ ++func NewVaultSyncSetFolderPausedCapability(folderID string, paused bool) VaultSyncConfigCapability { ++ return newVaultSyncConfigCapability(vaultSyncConfigOperationSetFolderPaused, folderID, paused, protocol.EmptyDeviceID) ++} ++ ++func NewVaultSyncShareFolderCapability(folderID string, deviceID protocol.DeviceID) VaultSyncConfigCapability { ++ return newVaultSyncConfigCapability(vaultSyncConfigOperationShareFolder, folderID, false, deviceID) ++} ++ ++func NewVaultSyncUnshareFolderCapability(folderID string, deviceID protocol.DeviceID) VaultSyncConfigCapability { ++ return newVaultSyncConfigCapability(vaultSyncConfigOperationUnshareFolder, folderID, false, deviceID) ++} ++ ++func newVaultSyncConfigCapability(operation vaultSyncConfigOperation, folderID string, paused bool, deviceID protocol.DeviceID) VaultSyncConfigCapability { ++ return VaultSyncConfigCapability{ ++ state: &vaultSyncConfigCapabilityState{ ++ operation: operation, ++ folderID: folderID, ++ paused: paused, ++ deviceID: deviceID, ++ }, ++ } ++} ++ ++func (c *vaultSyncConfigCapabilityState) consume() bool { ++ return c != nil && c.consumed.CompareAndSwap(false, true) ++} ++ ++// EnableVaultSyncReceiveSideProtection opts this wrapper into fail-closed ++// protected-folder configuration verification. ++func EnableVaultSyncReceiveSideProtection(cfg Wrapper) error { ++ w, ok := cfg.(*wrapper) ++ if !ok { ++ return ErrVaultSyncReceiveSideSafetyStop ++ } ++ ++ w.mut.Lock() ++ w.vaultSyncReceiveSideProtection = true ++ w.mut.Unlock() ++ return nil ++} ++ ++// ModifyWithVaultSyncCapability queues one config change with an exact, ++// one-shot protected-folder authorization. ++func ModifyWithVaultSyncCapability(cfg Wrapper, capability VaultSyncConfigCapability, modify ModifyFunction) (Waiter, error) { ++ w, ok := cfg.(*wrapper) ++ if !ok || capability.state == nil { ++ return noopWaiter{}, ErrVaultSyncReceiveSideSafetyStop ++ } ++ return w.modifyQueuedWithVaultSyncCapability(modify, capability.state, true) ++} ++ ++// prepareVaultSyncConfigurationLocked validates both the caller-provided raw ++// config and all deterministic changes derived by Configuration.prepare. ++func (w *wrapper) prepareVaultSyncConfigurationLocked(to Configuration, capability *vaultSyncConfigCapabilityState, capabilityRequested bool) (Configuration, bool, error) { ++ from := w.cfg ++ ++ if capabilityRequested { ++ expected, ok := capability.expectedConfiguration(from, w.myID) ++ if !ok || !reflect.DeepEqual(to, expected) { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ ++ preparedTo := to.Copy() ++ if err := preparedTo.prepare(w.myID); err != nil { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ preparedExpected, ok := capability.expectedPreparedConfiguration(expected) ++ if !ok || !reflect.DeepEqual(preparedTo, preparedExpected) || reflect.DeepEqual(from, preparedTo) { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ return preparedTo, true, nil ++ } ++ ++ if !vaultSyncProtectedFoldersEqual(from, to) { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ if reflect.DeepEqual(from, to) { ++ return to, false, nil ++ } ++ ++ protected := vaultSyncHasProtectedFolder(from) || vaultSyncHasProtectedFolder(to) ++ preparedTo := to.Copy() ++ if err := preparedTo.prepare(w.myID); err != nil { ++ if protected { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ return Configuration{}, false, err ++ } ++ if !vaultSyncProtectedFoldersEqual(from, preparedTo) { ++ return Configuration{}, false, ErrVaultSyncReceiveSideSafetyStop ++ } ++ return preparedTo, !reflect.DeepEqual(from, preparedTo), nil ++} ++ ++func (c *vaultSyncConfigCapabilityState) expectedConfiguration(from Configuration, myID protocol.DeviceID) (Configuration, bool) { ++ if c == nil || c.folderID == "" { ++ return Configuration{}, false ++ } ++ ++ expected := from.Copy() ++ folder, index, ok := expected.Folder(c.folderID) ++ if !ok || !vaultSyncFolderProtected(folder) { ++ return Configuration{}, false ++ } ++ ++ switch c.operation { ++ case vaultSyncConfigOperationRemoveFolder: ++ expected.Folders = append(expected.Folders[:index], expected.Folders[index+1:]...) ++ ++ case vaultSyncConfigOperationSetFolderPaused: ++ if folder.Paused == c.paused { ++ return Configuration{}, false ++ } ++ expected.Folders[index].Paused = c.paused ++ ++ case vaultSyncConfigOperationShareFolder: ++ if c.deviceID == protocol.EmptyDeviceID { ++ return Configuration{}, false ++ } ++ if _, _, ok := expected.Device(c.deviceID); !ok { ++ return Configuration{}, false ++ } ++ if folder.SharedWith(c.deviceID) { ++ return Configuration{}, false ++ } ++ expected.Folders[index].Devices = append(expected.Folders[index].Devices, FolderDeviceConfiguration{DeviceID: c.deviceID}) ++ ++ case vaultSyncConfigOperationUnshareFolder: ++ if c.deviceID == protocol.EmptyDeviceID || c.deviceID == myID || !folder.SharedWith(c.deviceID) { ++ return Configuration{}, false ++ } ++ devices := make([]FolderDeviceConfiguration, 0, len(folder.Devices)-1) ++ for _, device := range folder.Devices { ++ if device.DeviceID != c.deviceID { ++ devices = append(devices, device) ++ } ++ } ++ expected.Folders[index].Devices = devices ++ ++ default: ++ return Configuration{}, false ++ } ++ ++ return expected, true ++} ++ ++func (c *vaultSyncConfigCapabilityState) expectedPreparedConfiguration(expected Configuration) (Configuration, bool) { ++ if c == nil { ++ return Configuration{}, false ++ } ++ ++ // The wrapped configuration is already prepared. Normalize only the exact ++ // operation-local ordering that this capability adds; running prepare on the ++ // expectation would bless unrelated derived changes as part of the grant. ++ if c.operation == vaultSyncConfigOperationShareFolder { ++ _, index, ok := expected.Folder(c.folderID) ++ if !ok { ++ return Configuration{}, false ++ } ++ slices.SortFunc(expected.Folders[index].Devices, func(a, b FolderDeviceConfiguration) int { ++ return a.DeviceID.Compare(b.DeviceID) ++ }) ++ } ++ return expected, true ++} ++ ++func vaultSyncProtectedFoldersEqual(from, to Configuration) bool { ++ protectedIDs := make(map[string]struct{}) ++ for _, folder := range from.Folders { ++ if vaultSyncFolderProtected(folder) { ++ protectedIDs[folder.ID] = struct{}{} ++ } ++ } ++ for _, folder := range to.Folders { ++ if vaultSyncFolderProtected(folder) { ++ protectedIDs[folder.ID] = struct{}{} ++ } ++ } ++ ++ for folderID := range protectedIDs { ++ fromFolder, fromOK := vaultSyncUniqueFolder(from, folderID) ++ toFolder, toOK := vaultSyncUniqueFolder(to, folderID) ++ if !fromOK || !toOK || !reflect.DeepEqual(fromFolder, toFolder) { ++ return false ++ } ++ } ++ return true ++} ++ ++func vaultSyncUniqueFolder(cfg Configuration, folderID string) (FolderConfiguration, bool) { ++ var match FolderConfiguration ++ found := false ++ for _, folder := range cfg.Folders { ++ if folder.ID != folderID { ++ continue ++ } ++ if found { ++ return FolderConfiguration{}, false ++ } ++ match = folder ++ found = true ++ } ++ return match, found ++} ++ ++func vaultSyncHasProtectedFolder(cfg Configuration) bool { ++ for _, folder := range cfg.Folders { ++ if vaultSyncFolderProtected(folder) { ++ return true ++ } ++ } ++ return false ++} ++ ++func vaultSyncFolderProtected(folder FolderConfiguration) bool { ++ return folder.Type != FolderTypeSendOnly ++} +diff --git a/lib/config/wrapper.go b/lib/config/wrapper.go +index 9ecf67a..7617681 100644 +--- a/lib/config/wrapper.go ++++ b/lib/config/wrapper.go +@@ -133,9 +133,10 @@ type wrapper struct { + myID protocol.DeviceID + queue chan modifyEntry + +- waiter Waiter // Latest ongoing config change +- subs []Committer +- mut sync.Mutex ++ waiter Waiter // Latest ongoing config change ++ subs []Committer ++ vaultSyncReceiveSideProtection bool ++ mut sync.Mutex + + requiresRestart atomic.Bool + } +@@ -223,9 +224,15 @@ func (w *wrapper) Modify(fn ModifyFunction) (Waiter, error) { + } + + func (w *wrapper) modifyQueued(modifyFunc ModifyFunction) (Waiter, error) { ++ return w.modifyQueuedWithVaultSyncCapability(modifyFunc, nil, false) ++} ++ ++func (w *wrapper) modifyQueuedWithVaultSyncCapability(modifyFunc ModifyFunction, capability *vaultSyncConfigCapabilityState, capabilityRequested bool) (Waiter, error) { + e := modifyEntry{ +- modifyFunc: modifyFunc, +- res: make(chan modifyResult), ++ modifyFunc: modifyFunc, ++ vaultSyncCapability: capability, ++ capabilityRequested: capabilityRequested, ++ res: make(chan modifyResult), + } + select { + case w.queue <- e: +@@ -256,6 +263,27 @@ func (w *wrapper) Serve(ctx context.Context) error { + + var waiter Waiter = noopWaiter{} + var err error ++ var changed bool ++ ++ if e.capabilityRequested && !e.vaultSyncCapability.consume() { ++ e.res <- modifyResult{ ++ w: waiter, ++ err: ErrVaultSyncReceiveSideSafetyStop, ++ } ++ continue ++ } ++ if e.capabilityRequested { ++ w.mut.Lock() ++ protectionEnabled := w.vaultSyncReceiveSideProtection ++ w.mut.Unlock() ++ if !protectionEnabled { ++ e.res <- modifyResult{ ++ w: waiter, ++ err: ErrVaultSyncReceiveSideSafetyStop, ++ } ++ continue ++ } ++ } + + // Let the caller modify the config. + to := w.RawCopy() +@@ -263,12 +291,21 @@ func (w *wrapper) Serve(ctx context.Context) error { + + // Check if the config was actually changed at all. + w.mut.Lock() +- if !reflect.DeepEqual(w.cfg, to) { +- waiter, err = w.replaceLocked(to) +- if !saveTimerRunning { +- saveTimer.Reset(minSaveInterval) +- saveTimerRunning = true ++ if w.vaultSyncReceiveSideProtection { ++ to, changed, err = w.prepareVaultSyncConfigurationLocked(to, e.vaultSyncCapability, e.capabilityRequested) ++ if err == nil && changed { ++ waiter, err = w.replacePreparedLocked(to) ++ changed = err == nil + } ++ } else if e.capabilityRequested { ++ err = ErrVaultSyncReceiveSideSafetyStop ++ } else if !reflect.DeepEqual(w.cfg, to) { ++ waiter, err = w.replaceLocked(to) ++ changed = true ++ } ++ if changed && !saveTimerRunning { ++ saveTimer.Reset(minSaveInterval) ++ saveTimerRunning = true + } + w.mut.Unlock() + +@@ -302,11 +339,14 @@ func (w *wrapper) serveSave() { + } + + func (w *wrapper) replaceLocked(to Configuration) (Waiter, error) { +- from := w.cfg +- + if err := to.prepare(w.myID); err != nil { + return noopWaiter{}, err + } ++ return w.replacePreparedLocked(to) ++} ++ ++func (w *wrapper) replacePreparedLocked(to Configuration) (Waiter, error) { ++ from := w.cfg + + for _, sub := range w.subs { + sub, ok := sub.(Verifier) +@@ -529,8 +569,10 @@ func (w *wrapper) Save() error { + func (w *wrapper) RequiresRestart() bool { return w.requiresRestart.Load() } + + type modifyEntry struct { +- modifyFunc ModifyFunction +- res chan modifyResult ++ modifyFunc ModifyFunction ++ vaultSyncCapability *vaultSyncConfigCapabilityState ++ capabilityRequested bool ++ res chan modifyResult + } + + type modifyResult struct { +diff --git a/lib/model/folder_receive_side_readonly.go b/lib/model/folder_receive_side_readonly.go +new file mode 100644 +index 0000000..b1f270b +--- /dev/null ++++ b/lib/model/folder_receive_side_readonly.go +@@ -0,0 +1,140 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package model ++ ++import ( ++ "context" ++ "fmt" ++ "log/slog" ++ "time" ++ ++ "github.com/syncthing/syncthing/lib/config" ++ "github.com/syncthing/syncthing/lib/fs" ++ "github.com/syncthing/syncthing/lib/ignore" ++ "github.com/syncthing/syncthing/lib/protocol" ++ "github.com/syncthing/syncthing/lib/stats" ++ "github.com/syncthing/syncthing/lib/versioner" ++) ++ ++// receiveSideReadOnlyFolder is an application-selected hard floor. It keeps ++// the protocol-facing folder alive for authenticated index and upload traffic, ++// while deliberately providing no scanner, puller, watcher, cleanup, or local ++// filesystem service. ++type receiveSideReadOnlyFolder struct { ++ stateTracker ++} ++ ++func (m *model) receiveSideReadOnlyFolder(cfg config.FolderConfiguration) bool { ++ if !m.options.ReceiveSideReadOnly { ++ return false ++ } ++ switch cfg.Type { ++ case config.FolderTypeSendReceive, config.FolderTypeReceiveOnly, config.FolderTypeReceiveEncrypted: ++ return true ++ default: ++ return false ++ } ++} ++ ++func (m *model) receiveSideReadOnlyConfiguration(cfg config.Configuration) bool { ++ for _, folder := range cfg.Folders { ++ if m.receiveSideReadOnlyFolder(folder) { ++ return true ++ } ++ } ++ return false ++} ++ ++func (m *model) folderLogAttr(cfg config.FolderConfiguration) slog.Attr { ++ if m.receiveSideReadOnlyFolder(cfg) { ++ return slog.Group("folder", slog.String("id", cfg.ID), slog.String("type", cfg.Type.String())) ++ } ++ return cfg.LogAttr() ++} ++ ++func (m *model) protocolFolderLogAttr(folder protocol.Folder) slog.Attr { ++ if m.options.ReceiveSideReadOnly { ++ return slog.Group("folder", slog.String("id", folder.ID)) ++ } ++ return folder.LogAttr() ++} ++ ++func (m *model) folderDescription(cfg config.FolderConfiguration) string { ++ if m.receiveSideReadOnlyFolder(cfg) { ++ return cfg.ID ++ } ++ return cfg.Description() ++} ++ ++// Need to hold m.mut when calling this. ++func (m *model) addAndStartReceiveSideReadOnlyFolderLocked(cfg config.FolderConfiguration) { ++ if _, ok := m.folderRunners.Get(cfg.ID); ok { ++ slog.Error("Cannot start already running receive-side read-only folder", slog.String("folder", cfg.ID)) ++ panic("cannot start already running folder") ++ } ++ ++ var ver versioner.Versioner ++ if cfg.Versioning.Type != "" { ++ var err error ++ ver, err = versioner.NewVaultSyncInspectionOnly(cfg) ++ if err != nil { ++ panic(fmt.Errorf("creating inspection-only versioner: %w", err)) ++ } ++ } ++ ++ ignores := ignore.New(cfg.Filesystem()) ++ if cfg.Type != config.FolderTypeReceiveEncrypted { ++ if err := ignores.Load(".stignore"); err != nil && !fs.IsNotExist(err) { ++ slog.Warn("Receive-side read-only ignore policy could not be loaded", slog.String("folder", cfg.ID)) ++ } ++ } ++ ++ state := newStateTracker(cfg.ID, m.evLogger) ++ state.setError(config.ErrVaultSyncReceiveSideSafetyStop) ++ ++ m.folderCfgs[cfg.ID] = cfg ++ m.folderIgnores[cfg.ID] = ignores ++ m.folderVersioners[cfg.ID] = ver ++ m.folderRunners.Add(cfg.ID, &receiveSideReadOnlyFolder{ ++ stateTracker: state, ++ }) ++ ++ slog.Info("Receive-side read-only folder ready", slog.String("folder", cfg.ID), slog.String("type", cfg.Type.String())) ++} ++ ++func (f *receiveSideReadOnlyFolder) Serve(ctx context.Context) error { ++ <-ctx.Done() ++ return nil ++} ++ ++func (*receiveSideReadOnlyFolder) BringToFront(string) {} ++func (*receiveSideReadOnlyFolder) Override() {} ++func (*receiveSideReadOnlyFolder) Revert() {} ++func (*receiveSideReadOnlyFolder) DelayScan(time.Duration) {} ++func (*receiveSideReadOnlyFolder) ScheduleScan() {} ++func (*receiveSideReadOnlyFolder) SchedulePull() {} ++func (*receiveSideReadOnlyFolder) ScheduleForceRescan(string) {} ++ ++func (*receiveSideReadOnlyFolder) Jobs(_, _ int) ([]string, []string, int) { ++ return nil, nil, 0 ++} ++ ++func (*receiveSideReadOnlyFolder) Scan([]string) error { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++} ++ ++func (*receiveSideReadOnlyFolder) Errors() []FileError { ++ return []FileError{{Err: config.ErrVaultSyncReceiveSideSafetyStop.Error()}} ++} ++ ++func (*receiveSideReadOnlyFolder) WatchError() error { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++} ++ ++func (*receiveSideReadOnlyFolder) GetStatistics() (stats.FolderStatistics, error) { ++ return stats.FolderStatistics{}, nil ++} +diff --git a/lib/model/indexhandler.go b/lib/model/indexhandler.go +index 5cf73ab..5526a63 100644 +--- a/lib/model/indexhandler.go ++++ b/lib/model/indexhandler.go +@@ -79,22 +79,22 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f + // the IndexID, or something else weird has + // happened. We send a full index to reset the + // situation. +- slog.Warn("Peer is delta index compatible, but seems out of sync with reality", conn.DeviceID().LogAttr(), folder.LogAttr()) ++ slog.Warn("Peer is delta index compatible, but seems out of sync with reality", conn.DeviceID().LogAttr(), slog.String("folder", folder.ID)) + startSequence = 0 + } else { +- l.Debugf("Device %v folder %s is delta index compatible (mlv=%d)", conn.DeviceID().Short(), folder.Description(), startInfo.local.MaxSequence) ++ l.Debugf("Device %v folder %s is delta index compatible (mlv=%d)", conn.DeviceID().Short(), folder.ID, startInfo.local.MaxSequence) + startSequence = startInfo.local.MaxSequence + } + + case 0: +- l.Debugf("Device %v folder %s has no index ID for us", conn.DeviceID().Short(), folder.Description()) ++ l.Debugf("Device %v folder %s has no index ID for us", conn.DeviceID().Short(), folder.ID) + + default: + // They say they've seen an index ID from us, but it's + // not the right one. Either they are confused or we + // must have reset our database since last talking to + // them. We'll start with a full index transfer. +- slog.Warn("Peer has mismatching index ID for us", conn.DeviceID().LogAttr(), folder.LogAttr(), slog.Group("indexid", slog.Any("ours", myIndexID), slog.Any("theirs", startInfo.local.IndexID))) ++ slog.Warn("Peer has mismatching index ID for us", conn.DeviceID().LogAttr(), slog.String("folder", folder.ID), slog.Group("indexid", slog.Any("ours", myIndexID), slog.Any("theirs", startInfo.local.IndexID))) + startSequence = 0 + } + +@@ -109,7 +109,7 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f + // do not support delta indexes and we should clear any + // information we have from them before accepting their + // index, which will presumably be a full index. +- l.Debugf("Device %v folder %s does not announce an index ID", conn.DeviceID().Short(), folder.Description()) ++ l.Debugf("Device %v folder %s does not announce an index ID", conn.DeviceID().Short(), folder.ID) + if err := sdb.DropAllFiles(folder.ID, conn.DeviceID()); err != nil { + return nil, err + } +@@ -119,7 +119,7 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f + // will probably send us a full index. We drop any + // information we have and remember this new index ID + // instead. +- slog.Info("Peer has a new index ID", conn.DeviceID().LogAttr(), folder.LogAttr(), slog.Any("indexid", startInfo.remote.IndexID)) ++ slog.Info("Peer has a new index ID", conn.DeviceID().LogAttr(), slog.String("folder", folder.ID), slog.Any("indexid", startInfo.remote.IndexID)) + if err := sdb.DropAllFiles(folder.ID, conn.DeviceID()); err != nil { + return nil, err + } +diff --git a/lib/model/issue150_inert_runner_test.go b/lib/model/issue150_inert_runner_test.go +new file mode 100644 +index 0000000..9389025 +--- /dev/null ++++ b/lib/model/issue150_inert_runner_test.go +@@ -0,0 +1,443 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package model ++ ++import ( ++ "bytes" ++ "fmt" ++ "io" ++ "os" ++ "reflect" ++ "sort" ++ "sync" ++ "testing" ++ "time" ++ ++ "github.com/syncthing/syncthing/lib/config" ++ "github.com/syncthing/syncthing/lib/fs" ++ "github.com/syncthing/syncthing/lib/protocol" ++ "github.com/syncthing/syncthing/lib/rand" ++ "github.com/syncthing/syncthing/lib/scanner" ++) ++ ++const issue150TrackingFilesystemType fs.FilesystemType = "issue150-tracking" ++ ++var issue150TrackingFilesystems sync.Map ++ ++func init() { ++ fs.RegisterFilesystemType(issue150TrackingFilesystemType, func(root string, opts ...fs.Option) (fs.Filesystem, error) { ++ if existing, ok := issue150TrackingFilesystems.Load(root); ok { ++ return existing.(*issue150TrackingFilesystem), nil ++ } ++ created := &issue150TrackingFilesystem{ ++ Filesystem: fs.NewFilesystem(fs.FilesystemTypeFake, root, opts...), ++ } ++ actual, _ := issue150TrackingFilesystems.LoadOrStore(root, created) ++ return actual.(*issue150TrackingFilesystem), nil ++ }) ++} ++ ++func TestIssue150ProtectedSendReceiveRemoteIndexPreservesLocalStateForEveryMaxConflictsValue(t *testing.T) { ++ for _, maxConflicts := range []int{0, 10, -1} { ++ t.Run(fmt.Sprintf("MaxConflicts=%d", maxConflicts), func(t *testing.T) { ++ m, folder, tracking := issue150InertRunnerModel(t, maxConflicts) ++ defer cleanupModel(m) ++ ++ localFiles := issue150SeedLocalFixtures(t, m, folder) ++ conn := addFakeConn(m, device1, folder.ID) ++ beforeFilesystem := issue150FilesystemState(t, folder.Filesystem()) ++ beforeLocalIndex := issue150LocalIndexState(t, m, folder.ID) ++ tracking.enableMutationTracking() ++ ++ remoteContents := []byte("remote fixture") ++ conn.addFile(localFiles[0].Name, 0o644, protocol.FileInfoTypeFile, remoteContents) ++ remoteFull := issue150FixtureFile(t, localFiles[0].Name, remoteContents, device1.Short(), 1) ++ remoteFull.ModifiedS = localFiles[0].ModifiedS + 60 ++ if err := m.Index(conn, &protocol.Index{ ++ Folder: folder.ID, ++ Files: []protocol.FileInfo{remoteFull}, ++ LastSequence: 1, ++ }); err != nil { ++ t.Fatal(err) ++ } ++ ++ deltaContents := []byte("delta fixture") ++ const deltaName = "incoming.md" ++ conn.addFile(deltaName, 0o644, protocol.FileInfoTypeFile, deltaContents) ++ remoteDelta := issue150FixtureFile(t, deltaName, deltaContents, device1.Short(), 2) ++ if err := m.IndexUpdate(conn, &protocol.IndexUpdate{ ++ Folder: folder.ID, ++ Files: []protocol.FileInfo{remoteDelta}, ++ PrevSequence: 1, ++ LastSequence: 2, ++ }); err != nil { ++ t.Fatal(err) ++ } ++ ++ if runner, ok := m.folderRunners.Get(folder.ID); !ok { ++ t.Fatal("protected folder runner is missing") ++ } else { ++ runner.SchedulePull() ++ } ++ issue150WaitForForbiddenRunnerActivity(tracking, conn) ++ ++ if got := issue150NeedCount(t, m, folder.ID); got != 2 { ++ t.Errorf("authenticated remote need = %d, want 2", got) ++ } ++ for _, remote := range []protocol.FileInfo{remoteFull, remoteDelta} { ++ if _, ok, err := m.sdb.GetDeviceFile(folder.ID, device1, remote.Name); err != nil { ++ t.Fatal(err) ++ } else if !ok { ++ t.Errorf("authenticated remote index did not persist %q", remote.Name) ++ } ++ } ++ if got := tracking.mutationOperations(); len(got) != 0 { ++ t.Errorf("protected runner reached filesystem mutation operations: %v", got) ++ } ++ if got := conn.RequestCallCount(); got != 0 { ++ t.Errorf("protected runner made %d remote block requests", got) ++ } ++ if after := issue150FilesystemState(t, folder.Filesystem()); !reflect.DeepEqual(after, beforeFilesystem) { ++ t.Error("remote index processing changed the local filesystem") ++ } ++ if after := issue150LocalIndexState(t, m, folder.ID); !reflect.DeepEqual(after, beforeLocalIndex) { ++ t.Error("remote index processing changed the local index") ++ } ++ }) ++ } ++} ++ ++func TestIssue150ProtectedRunnerIgnoresAtomicReplaceDuringRemoteIndex(t *testing.T) { ++ m, folder, tracking := issue150InertRunnerModel(t, -1) ++ defer cleanupModel(m) ++ ++ localFiles := issue150SeedLocalFixtures(t, m, folder) ++ conn := addFakeConn(m, device1, folder.ID) ++ beforeLocalIndex := issue150LocalIndexState(t, m, folder.ID) ++ tracking.enableMutationTracking() ++ ++ replacementContents := []byte("replacement fixture") ++ remoteContents := []byte("remote fixture") ++ conn.addFile(localFiles[0].Name, 0o644, protocol.FileInfoTypeFile, remoteContents) ++ remote := issue150FixtureFile(t, localFiles[0].Name, remoteContents, device1.Short(), 1) ++ remote.ModifiedS = localFiles[0].ModifiedS + 60 ++ ++ runner, ok := m.folderRunners.Get(folder.ID) ++ if !ok { ++ t.Fatal("protected folder runner is missing") ++ } ++ start := make(chan struct{}) ++ indexResult := make(chan error, 1) ++ replaceResult := make(chan error, 1) ++ go func() { ++ <-start ++ err := m.Index(conn, &protocol.Index{ ++ Folder: folder.ID, ++ Files: []protocol.FileInfo{remote}, ++ LastSequence: 1, ++ }) ++ runner.SchedulePull() ++ indexResult <- err ++ }() ++ go func() { ++ <-start ++ const pendingName = "document.pending" ++ if err := fs.WriteFile(tracking.Filesystem, pendingName, replacementContents, 0o644); err != nil { ++ replaceResult <- err ++ return ++ } ++ replaceResult <- tracking.Filesystem.Rename(pendingName, localFiles[0].Name) ++ }() ++ close(start) ++ if err := <-indexResult; err != nil { ++ t.Fatal(err) ++ } ++ if err := <-replaceResult; err != nil { ++ t.Fatal(err) ++ } ++ issue150WaitForForbiddenRunnerActivity(tracking, conn) ++ ++ if got := issue150NeedCount(t, m, folder.ID); got != 1 { ++ t.Errorf("authenticated remote need = %d, want 1", got) ++ } ++ if got := tracking.mutationOperations(); len(got) != 0 { ++ t.Errorf("protected runner reached filesystem mutation operations: %v", got) ++ } ++ if got := conn.RequestCallCount(); got != 0 { ++ t.Errorf("protected runner made %d remote block requests", got) ++ } ++ if after := issue150LocalIndexState(t, m, folder.ID); !reflect.DeepEqual(after, beforeLocalIndex) { ++ t.Error("concurrent remote index processing changed the local index") ++ } ++ if got, err := issue150ReadFixture(folder.Filesystem(), localFiles[0].Name); err != nil { ++ t.Fatal(err) ++ } else if !bytes.Equal(got, replacementContents) { ++ t.Error("external atomic replacement was not preserved") ++ } ++ for _, file := range localFiles[1:] { ++ got, err := issue150ReadFixture(folder.Filesystem(), file.Name) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !bytes.Equal(got, []byte("conflict fixture")) { ++ t.Errorf("pre-existing conflict-like file %q changed", file.Name) ++ } ++ } ++} ++ ++func issue150InertRunnerModel(t *testing.T, maxConflicts int) (*testModel, config.FolderConfiguration, *issue150TrackingFilesystem) { ++ t.Helper() ++ cfg := defaultCfgWrapper.RawCopy() ++ folder := newFolderConfig() ++ folder.ID = "issue150-runner-" + rand.String(8) ++ folder.Label = "Issue 150 Fixture" ++ folder.Type = config.FolderTypeSendReceive ++ folder.FilesystemType = config.FilesystemType(issue150TrackingFilesystemType) ++ folder.Path = "issue150-runner-" + rand.String(16) + "?content=true" ++ folder.MaxConflicts = maxConflicts ++ folder.RescanIntervalS = 0 ++ folder.FSWatcherEnabled = false ++ folder.PullerDelayS = 0 ++ cfg.Folders = []config.FolderConfiguration{folder} ++ ++ wrapper, cancel := newConfigWrapper(cfg) ++ t.Cleanup(cancel) ++ m := issue150NewReadOnlyModel(t, wrapper) ++ m.ServeBackground() ++ _ = m.ScanFolder(folder.ID) ++ ++ value, ok := issue150TrackingFilesystems.Load(folder.Path) ++ if !ok { ++ cleanupModel(m) ++ t.Fatal("tracking filesystem was not constructed") ++ } ++ return m, folder, value.(*issue150TrackingFilesystem) ++} ++ ++func issue150SeedLocalFixtures(t *testing.T, m *testModel, folder config.FolderConfiguration) []protocol.FileInfo { ++ t.Helper() ++ folderFS := folder.Filesystem() ++ localFiles := make([]protocol.FileInfo, 0, 13) ++ canonicalContents := []byte("local fixture") ++ writeFile(t, folderFS, "document.md", canonicalContents) ++ localFiles = append(localFiles, issue150FixtureFile(t, "document.md", canonicalContents, myID.Short(), 1)) ++ for i := 0; i < 12; i++ { ++ name := fmt.Sprintf("document.sync-conflict-20200101-%06d-ABCDEF.md", i) ++ contents := []byte("conflict fixture") ++ writeFile(t, folderFS, name, contents) ++ localFiles = append(localFiles, issue150FixtureFile(t, name, contents, myID.Short(), int64(i+2))) ++ } ++ if err := m.sdb.Update(folder.ID, protocol.LocalDeviceID, localFiles); err != nil { ++ t.Fatal(err) ++ } ++ return localFiles ++} ++ ++func issue150FixtureFile(t *testing.T, name string, contents []byte, device protocol.ShortID, sequence int64) protocol.FileInfo { ++ t.Helper() ++ blockSize := protocol.BlockSize(int64(len(contents))) ++ blocks, err := scanner.Blocks(t.Context(), bytes.NewReader(contents), blockSize, int64(len(contents)), nil) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return protocol.FileInfo{ ++ Name: name, ++ Type: protocol.FileInfoTypeFile, ++ Size: int64(len(contents)), ++ ModifiedS: time.Now().Unix(), ++ Permissions: 0o644, ++ RawBlockSize: int32(blockSize), ++ Blocks: blocks, ++ Version: protocol.Vector{}.Update(device), ++ Sequence: sequence, ++ } ++} ++ ++func issue150NeedCount(t *testing.T, m *testModel, folder string) int { ++ t.Helper() ++ counts, err := m.NeedSize(folder, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return counts.TotalItems() ++} ++ ++func issue150LocalIndexState(t *testing.T, m *testModel, folder string) []protocol.FileInfo { ++ t.Helper() ++ sequence, err := m.Sequence(folder, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ files := make([]protocol.FileInfo, 0) ++ all, checkError := m.sdb.AllLocalFiles(folder, protocol.LocalDeviceID) ++ for file := range all { ++ files = append(files, file) ++ } ++ if err := checkError(); err != nil { ++ t.Fatal(err) ++ } ++ sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) ++ return append([]protocol.FileInfo{{Sequence: sequence}}, files...) ++} ++ ++type issue150FilesystemEntry struct { ++ Mode fs.FileMode ++ Size int64 ++ ModifiedNanos int64 ++ Contents []byte ++} ++ ++func issue150FilesystemState(t *testing.T, filesystem fs.Filesystem) map[string]issue150FilesystemEntry { ++ t.Helper() ++ state := make(map[string]issue150FilesystemEntry) ++ if err := filesystem.Walk(".", func(path string, info fs.FileInfo, err error) error { ++ if err != nil { ++ return err ++ } ++ entry := issue150FilesystemEntry{ ++ Mode: info.Mode(), ++ Size: info.Size(), ++ ModifiedNanos: info.ModTime().UnixNano(), ++ } ++ if info.IsRegular() { ++ contents, err := issue150ReadFixture(filesystem, path) ++ if err != nil { ++ return err ++ } ++ entry.Contents = contents ++ } ++ state[path] = entry ++ return nil ++ }); err != nil { ++ t.Fatal(err) ++ } ++ return state ++} ++ ++func issue150ReadFixture(filesystem fs.Filesystem, path string) ([]byte, error) { ++ file, err := filesystem.Open(path) ++ if err != nil { ++ return nil, err ++ } ++ defer file.Close() ++ return io.ReadAll(file) ++} ++ ++func issue150WaitForForbiddenRunnerActivity(tracking *issue150TrackingFilesystem, conn *fakeConnection) { ++ deadline := time.Now().Add(500 * time.Millisecond) ++ for time.Now().Before(deadline) { ++ if len(tracking.mutationOperations()) > 0 || conn.RequestCallCount() > 0 { ++ return ++ } ++ time.Sleep(5 * time.Millisecond) ++ } ++} ++ ++type issue150TrackingFilesystem struct { ++ fs.Filesystem ++ ++ mut sync.Mutex ++ enabled bool ++ operations []string ++} ++ ++func (f *issue150TrackingFilesystem) enableMutationTracking() { ++ f.mut.Lock() ++ f.enabled = true ++ f.operations = nil ++ f.mut.Unlock() ++} ++ ++func (f *issue150TrackingFilesystem) mutationOperations() []string { ++ f.mut.Lock() ++ defer f.mut.Unlock() ++ return append([]string(nil), f.operations...) ++} ++ ++func (f *issue150TrackingFilesystem) record(operation string) { ++ f.mut.Lock() ++ defer f.mut.Unlock() ++ if f.enabled { ++ f.operations = append(f.operations, operation) ++ } ++} ++ ++func (f *issue150TrackingFilesystem) Type() fs.FilesystemType { ++ return issue150TrackingFilesystemType ++} ++ ++func (f *issue150TrackingFilesystem) Chmod(name string, mode fs.FileMode) error { ++ f.record("chmod") ++ return f.Filesystem.Chmod(name, mode) ++} ++ ++func (f *issue150TrackingFilesystem) Lchown(name, uid, gid string) error { ++ f.record("lchown") ++ return f.Filesystem.Lchown(name, uid, gid) ++} ++ ++func (f *issue150TrackingFilesystem) Chtimes(name string, atime, mtime time.Time) error { ++ f.record("chtimes") ++ return f.Filesystem.Chtimes(name, atime, mtime) ++} ++ ++func (f *issue150TrackingFilesystem) Create(name string) (fs.File, error) { ++ f.record("create") ++ return f.Filesystem.Create(name) ++} ++ ++func (f *issue150TrackingFilesystem) CreateSymlink(target, name string) error { ++ f.record("create-symlink") ++ return f.Filesystem.CreateSymlink(target, name) ++} ++ ++func (f *issue150TrackingFilesystem) Mkdir(name string, mode fs.FileMode) error { ++ f.record("mkdir") ++ return f.Filesystem.Mkdir(name, mode) ++} ++ ++func (f *issue150TrackingFilesystem) MkdirAll(name string, mode fs.FileMode) error { ++ f.record("mkdir-all") ++ return f.Filesystem.MkdirAll(name, mode) ++} ++ ++func (f *issue150TrackingFilesystem) OpenFile(name string, flags int, mode fs.FileMode) (fs.File, error) { ++ if flags&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0 { ++ f.record("open-file-for-write") ++ } ++ return f.Filesystem.OpenFile(name, flags, mode) ++} ++ ++func (f *issue150TrackingFilesystem) Remove(name string) error { ++ f.record("remove") ++ return f.Filesystem.Remove(name) ++} ++ ++func (f *issue150TrackingFilesystem) RemoveAll(name string) error { ++ f.record("remove-all") ++ return f.Filesystem.RemoveAll(name) ++} ++ ++func (f *issue150TrackingFilesystem) Rename(oldName, newName string) error { ++ f.record("rename") ++ return f.Filesystem.Rename(oldName, newName) ++} ++ ++func (f *issue150TrackingFilesystem) Hide(name string) error { ++ f.record("hide") ++ return f.Filesystem.Hide(name) ++} ++ ++func (f *issue150TrackingFilesystem) Unhide(name string) error { ++ f.record("unhide") ++ return f.Filesystem.Unhide(name) ++} ++ ++func (f *issue150TrackingFilesystem) SetXattr(path string, xattrs []protocol.Xattr, filter fs.XattrFilter) error { ++ f.record("set-xattr") ++ return f.Filesystem.SetXattr(path, xattrs, filter) ++} +diff --git a/lib/model/issue150_runtime_privacy_test.go b/lib/model/issue150_runtime_privacy_test.go +new file mode 100644 +index 0000000..fc67758 +--- /dev/null ++++ b/lib/model/issue150_runtime_privacy_test.go +@@ -0,0 +1,524 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package model ++ ++import ( ++ "bytes" ++ "context" ++ "errors" ++ "log/slog" ++ "os" ++ "path/filepath" ++ "reflect" ++ "slices" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/syncthing/syncthing/internal/db/sqlite" ++ "github.com/syncthing/syncthing/internal/slogutil" ++ "github.com/syncthing/syncthing/lib/config" ++ "github.com/syncthing/syncthing/lib/events" ++ "github.com/syncthing/syncthing/lib/fs" ++ "github.com/syncthing/syncthing/lib/protocol" ++ "github.com/syncthing/syncthing/lib/rand" ++ "github.com/syncthing/syncthing/lib/scanner" ++) ++ ++func TestIssue150ProtectedRunnerStartsWithoutMarkerOrLocalIndex(t *testing.T) { ++ for _, folderType := range []config.FolderType{ ++ config.FolderTypeSendReceive, ++ config.FolderTypeReceiveOnly, ++ config.FolderTypeReceiveEncrypted, ++ } { ++ t.Run(folderType.String(), func(t *testing.T) { ++ m, fcfg := issue150RuntimeModel(t, folderType, "?files=1&content=true&nostfolder=true", true) ++ defer cleanupModel(m) ++ ++ state, _, err := m.State(fcfg.ID) ++ issue150RequireRuntimeSafetyError(t, err) ++ if state != FolderError.String() { ++ t.Errorf("protected state = %q, want %q", state, FolderError.String()) ++ } ++ folderErrors, err := m.FolderErrors(fcfg.ID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if len(folderErrors) != 1 || folderErrors[0].Path != "" || folderErrors[0].Err != config.ErrVaultSyncReceiveSideSafetyStop.Error() { ++ t.Errorf("protected folder errors = %#v, want one path-free safety stop", folderErrors) ++ } ++ ++ issue150RequireRuntimeSafetyError(t, m.ScanFolder(fcfg.ID)) ++ issue150RequireRuntimeSafetyError(t, m.ScanFolderSubdirs(fcfg.ID, []string{"subdir"})) ++ m.Revert(fcfg.ID) ++ ++ if _, err := fcfg.Filesystem().Lstat(config.DefaultMarkerName); !fs.IsNotExist(err) { ++ t.Errorf("protected startup created or accessed a persistent marker: %v", err) ++ } ++ counts, err := m.LocalSize(fcfg.ID, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if got := counts.TotalItems(); got != 0 { ++ t.Errorf("protected startup or scan persisted %d local index items", got) ++ } ++ }) ++ } ++ ++ for _, control := range []struct { ++ name string ++ folderType config.FolderType ++ }{ ++ {name: "receive-only-normal-control", folderType: config.FolderTypeReceiveOnly}, ++ {name: "send-only-control", folderType: config.FolderTypeSendOnly}, ++ } { ++ t.Run(control.name, func(t *testing.T) { ++ m, fcfg := issue150RuntimeModel(t, control.folderType, "?files=1&content=true&nostfolder=true", false) ++ defer cleanupModel(m) ++ ++ if err := m.ScanFolder(fcfg.ID); err != nil { ++ t.Fatal(err) ++ } ++ if _, err := fcfg.Filesystem().Lstat(config.DefaultMarkerName); err != nil { ++ t.Errorf("normal startup did not retain marker semantics: %v", err) ++ } ++ counts, err := m.LocalSize(fcfg.ID, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if got := counts.TotalItems(); got == 0 { ++ t.Error("normal scan no longer persists its local index") ++ } ++ }) ++ } ++} ++ ++func TestIssue150ProtectedFullAndDeltaPersistAuthenticatedNeed(t *testing.T) { ++ m, fcfg := issue150RuntimeModel(t, config.FolderTypeReceiveOnly, "?content=true", true) ++ defer cleanupModel(m) ++ oldLevel := slogutil.PackageLevels()["model"] ++ slogutil.SetPackageLevel("model", slog.LevelDebug) ++ t.Cleanup(func() { slogutil.SetPackageLevel("model", oldLevel) }) ++ slogutil.GlobalRecorder.Clear() ++ conn := addFakeConn(m, device1, fcfg.ID) ++ ++ full := issue150RuntimeFile(t, "full-note.md", []byte("full"), device1.Short(), 1) ++ if err := m.Index(conn, &protocol.Index{ ++ Folder: fcfg.ID, ++ Files: []protocol.FileInfo{full}, ++ LastSequence: 1, ++ }); err != nil { ++ t.Fatal(err) ++ } ++ issue150ExpectNeed(t, m, fcfg.ID, 1) ++ ++ delta := issue150RuntimeFile(t, "delta-note.md", []byte("delta"), device1.Short(), 2) ++ if err := m.IndexUpdate(conn, &protocol.IndexUpdate{ ++ Folder: fcfg.ID, ++ Files: []protocol.FileInfo{delta}, ++ PrevSequence: 1, ++ LastSequence: 2, ++ }); err != nil { ++ t.Fatal(err) ++ } ++ issue150ExpectNeed(t, m, fcfg.ID, 2) ++ ++ for _, name := range []string{full.Name, delta.Name} { ++ if _, ok, err := m.sdb.GetDeviceFile(fcfg.ID, device1, name); err != nil { ++ t.Fatal(err) ++ } else if !ok { ++ t.Errorf("authenticated remote index did not persist %q", name) ++ } ++ } ++ logs := issue150RecordedLogs() ++ for _, secret := range []string{fcfg.Label, fcfg.Description(), fcfg.Path} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected remote index log contains user-derived value %q: %s", secret, logs) ++ } ++ } ++} ++ ++func TestIssue150ProtectedRequestKeepsReadsButDoesNotRecheckOrLeak(t *testing.T) { ++ for _, tc := range []struct { ++ name string ++ folderType config.FolderType ++ protected bool ++ expectHashMismatch bool ++ expectRecheck bool ++ }{ ++ {name: "send-receive", folderType: config.FolderTypeSendReceive, protected: true, expectHashMismatch: true}, ++ {name: "receive-only", folderType: config.FolderTypeReceiveOnly, protected: true, expectHashMismatch: true}, ++ {name: "receive-encrypted", folderType: config.FolderTypeReceiveEncrypted, protected: true}, ++ {name: "receive-only-normal-control", folderType: config.FolderTypeReceiveOnly, expectHashMismatch: true, expectRecheck: true}, ++ {name: "send-only-control", folderType: config.FolderTypeSendOnly, expectHashMismatch: true, expectRecheck: true}, ++ } { ++ t.Run(tc.name, func(t *testing.T) { ++ m, fcfg := issue150RuntimeModel(t, tc.folderType, "?content=true", tc.protected) ++ defer cleanupModel(m) ++ if !tc.protected { ++ // Wait for the ordinary runner's initial scan before measuring the ++ // positive forced-rescan control. ++ if err := m.ScanFolder(fcfg.ID); err != nil { ++ t.Fatal(err) ++ } ++ } ++ conn := addFakeConn(m, device1, fcfg.ID) ++ folderFS := fcfg.Filesystem() ++ ++ const name = "issue150-redaction-probe.md" ++ original := []byte("original bytes") ++ changed := []byte("modified bytes") ++ if len(original) != len(changed) { ++ t.Fatal("test fixture sizes differ") ++ } ++ if err := fs.WriteFile(folderFS, name, original, 0o644); err != nil { ++ t.Fatal(err) ++ } ++ local := issue150RuntimeFile(t, name, original, myID.Short(), 1) ++ if err := m.sdb.Update(fcfg.ID, protocol.LocalDeviceID, []protocol.FileInfo{local}); err != nil { ++ t.Fatal(err) ++ } ++ ++ if tc.protected && tc.folderType != config.FolderTypeReceiveEncrypted { ++ const ignoredName = "issue150-redaction-probe-ignored.md" ++ if err := fs.WriteFile(folderFS, ".stignore", []byte(ignoredName+"\n"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ if err := fs.WriteFile(folderFS, ignoredName, []byte("ignored bytes"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ beforeSequence, err := m.Sequence(fcfg.ID, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ oldLevel := slogutil.PackageLevels()["model"] ++ slogutil.SetPackageLevel("model", slog.LevelDebug) ++ t.Cleanup(func() { slogutil.SetPackageLevel("model", oldLevel) }) ++ slogutil.GlobalRecorder.Clear() ++ ignoredResponse, err := m.Request(conn, &protocol.Request{Folder: fcfg.ID, Name: ignoredName, Size: len("ignored bytes")}) ++ if err == nil { ++ t.Error("ignored protected request unexpectedly succeeded") ++ } ++ if ignoredResponse != nil { ++ ignoredResponse.Close() ++ } ++ afterSequence, err := m.Sequence(fcfg.ID, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if afterSequence != beforeSequence { ++ t.Errorf("ignored protected request changed local sequence from %d to %d", beforeSequence, afterSequence) ++ } ++ lines, _, err := m.CurrentIgnores(fcfg.ID) ++ if err != nil { ++ t.Fatalf("protected ignore inspection failed: %v", err) ++ } ++ if !slices.Contains(lines, ignoredName) { ++ t.Errorf("protected ignore inspection returned %q, want %q", lines, ignoredName) ++ } ++ logs := issue150RecordedLogs() ++ for _, secret := range []string{ignoredName, fcfg.Label, fcfg.Path} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("ignored protected request log contains user-derived value %q: %s", secret, logs) ++ } ++ } ++ } ++ ++ // Supported uploads remain available for every folder type. ++ res, err := m.Request(conn, &protocol.Request{Folder: fcfg.ID, Name: name, Size: len(original)}) ++ if err != nil { ++ t.Fatalf("valid request was disabled: %v", err) ++ } ++ if got := append([]byte(nil), res.Data()...); !bytes.Equal(got, original) { ++ t.Errorf("valid request returned %q, want %q", got, original) ++ } ++ res.Close() ++ ++ if err := fs.WriteFile(folderFS, name, changed, 0o644); err != nil { ++ t.Fatal(err) ++ } ++ oldLevel := slogutil.PackageLevels()["model"] ++ slogutil.SetPackageLevel("model", slog.LevelDebug) ++ t.Cleanup(func() { slogutil.SetPackageLevel("model", oldLevel) }) ++ slogutil.GlobalRecorder.Clear() ++ ++ mismatchResponse, err := m.Request(conn, &protocol.Request{ ++ Folder: fcfg.ID, ++ Name: name, ++ Size: len(original), ++ Hash: local.Blocks[0].Hash, ++ }) ++ if tc.expectHashMismatch && err == nil { ++ t.Error("hash mismatch unexpectedly succeeded") ++ } ++ if !tc.expectHashMismatch && err != nil { ++ t.Errorf("receive-encrypted request semantics changed: %v", err) ++ } ++ if mismatchResponse != nil { ++ mismatchResponse.Close() ++ } ++ ++ changedInDB := issue150WaitForBlockChange(t, m, fcfg.ID, name, local.Blocks[0].Hash) ++ if changedInDB != tc.expectRecheck { ++ t.Errorf("recheck mutation = %v, want %v", changedInDB, tc.expectRecheck) ++ } ++ if tc.protected { ++ logs := issue150RecordedLogs() ++ for _, secret := range []string{name, fcfg.Label, fcfg.Path} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected request log contains user-derived value %q: %s", secret, logs) ++ } ++ } ++ } ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideReadOnlyDisablesAutoAcceptBeforeConfigOrFilesystem(t *testing.T) { ++ cfg := defaultAutoAcceptCfg.Copy() ++ autoAcceptRoot := filepath.Join(t.TempDir(), "auto-accept-root") ++ cfg.Defaults.Folder.FilesystemType = config.FilesystemTypeBasic ++ cfg.Defaults.Folder.Path = autoAcceptRoot ++ folder := newFolderConfig() ++ folder.ID = "issue150-sendonly-control" ++ folder.Type = config.FolderTypeSendOnly ++ folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{DeviceID: device1}) ++ cfg.Folders = []config.FolderConfiguration{folder} ++ ++ w, cancel := newConfigWrapper(cfg) ++ defer cancel() ++ m := issue150NewReadOnlyModel(t, w) ++ m.ServeBackground() ++ defer cleanupModel(m) ++ conn := addFakeConn(m, device1, folder.ID) ++ before := w.RawCopy() ++ ++ const offeredFolder = "issue150-offered-fixture" ++ if err := m.ClusterConfig(conn, createClusterConfig(device1, offeredFolder)); err != nil { ++ t.Fatal(err) ++ } ++ if !reflect.DeepEqual(w.RawCopy(), before) { ++ t.Error("receive-side read-only auto-accept changed configuration") ++ } ++ if _, ok := w.Folder(offeredFolder); ok { ++ t.Error("receive-side read-only auto-accept added the offered folder") ++ } ++ if _, err := os.Stat(autoAcceptRoot); !os.IsNotExist(err) { ++ t.Errorf("receive-side read-only auto-accept touched its filesystem root: %v", err) ++ } ++} ++ ++func TestIssue150ProtectedIntroducerDiffDoesNotLogLabelBeforeConfigGuard(t *testing.T) { ++ for _, tc := range []struct { ++ name string ++ localShared bool ++ remoteShared bool ++ }{ ++ {name: "introduction", remoteShared: true}, ++ {name: "deintroduction", localShared: true}, ++ } { ++ t.Run(tc.name, func(t *testing.T) { ++ cfg := defaultCfgWrapper.RawCopy() ++ _, introducerIndex, ok := cfg.Device(device1) ++ if !ok { ++ t.Fatal("#150 introducer fixture device is missing") ++ } ++ cfg.Devices[introducerIndex].Introducer = true ++ ++ peer := cfg.Defaults.Device.Copy() ++ peer.DeviceID = device2 ++ peer.Name = "Issue 150 peer" ++ if tc.localShared { ++ peer.IntroducedBy = device1 ++ } ++ cfg.SetDevice(peer) ++ ++ folder := newFolderConfig() ++ folder.ID = "issue150-protected-introducer" ++ folder.Label = "Issue 150 Local Vault Label" ++ folder.Type = config.FolderTypeReceiveOnly ++ folder.Devices = []config.FolderDeviceConfiguration{ ++ {DeviceID: myID}, ++ {DeviceID: device1}, ++ } ++ if tc.localShared { ++ folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{ ++ DeviceID: device2, ++ IntroducedBy: device1, ++ }) ++ } ++ cfg.Folders = []config.FolderConfiguration{folder} ++ ++ wrapper, cancel := newConfigWrapper(cfg) ++ defer cancel() ++ if err := config.EnableVaultSyncReceiveSideProtection(wrapper); err != nil { ++ t.Fatalf("enable #150 config protection: %v", err) ++ } ++ m := issue150NewReadOnlyModel(t, wrapper) ++ m.ServeBackground() ++ defer cleanupModel(m) ++ ++ conn := newFakeConnection(device1, m) ++ conn.folder = folder.ID ++ m.AddConnection(conn, protocol.Hello{}) ++ remoteFolder := protocol.Folder{ ++ ID: folder.ID, ++ Label: "Issue 150 Remote Vault Label", ++ Devices: []protocol.Device{ ++ {ID: myID}, ++ {ID: device1}, ++ }, ++ } ++ if tc.remoteShared { ++ remoteFolder.Devices = append(remoteFolder.Devices, protocol.Device{ID: device2}) ++ } ++ ++ before := wrapper.RawCopy() ++ slogutil.GlobalRecorder.Clear() ++ if err := m.ClusterConfig(conn, &protocol.ClusterConfig{Folders: []protocol.Folder{remoteFolder}}); err != nil { ++ t.Fatalf("handle protected introducer config: %v", err) ++ } ++ if after := wrapper.RawCopy(); !reflect.DeepEqual(after, before) { ++ t.Fatalf("protected introducer diff changed config:\nbefore=%+v\nafter=%+v", before, after) ++ } ++ logs := issue150RecordedLogs() ++ for _, secret := range []string{folder.Label, remoteFolder.Label} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected introducer log contains user-derived label %q: %s", secret, logs) ++ } ++ } ++ }) ++ } ++} ++ ++func issue150RuntimeModel(t *testing.T, folderType config.FolderType, pathSuffix string, receiveSideReadOnly bool) (*testModel, config.FolderConfiguration) { ++ t.Helper() ++ cfg := defaultCfgWrapper.RawCopy() ++ fcfg := newFolderConfig() ++ fcfg.ID = "issue150-runtime-" + rand.String(8) ++ fcfg.Label = "Issue 150 Redaction Probe" ++ fcfg.Type = folderType ++ fcfg.Path = "issue150-runtime-" + rand.String(16) + pathSuffix ++ fcfg.RescanIntervalS = 0 ++ fcfg.FSWatcherEnabled = false ++ // Keep positive remote-index tests deterministic. Protected runners must ++ // remain inert independently of this delay; send-only scanning is unaffected. ++ fcfg.PullerDelayS = 3600 ++ cfg.Folders = []config.FolderConfiguration{fcfg} ++ ++ w, cancel := newConfigWrapper(cfg) ++ t.Cleanup(cancel) ++ var m *testModel ++ if receiveSideReadOnly { ++ m = issue150NewReadOnlyModel(t, w) ++ } else { ++ m = newModel(t, w, myID, nil) ++ } ++ m.ServeBackground() ++ return m, fcfg ++} ++ ++func issue150NewReadOnlyModel(t *testing.T, cfg config.Wrapper) *testModel { ++ t.Helper() ++ evLogger := events.NewLogger() ++ mdb, err := sqlite.Open(t.TempDir()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ t.Cleanup(func() { mdb.Close() }) ++ m := NewModelWithOptions( ++ cfg, ++ myID, ++ mdb, ++ nil, ++ evLogger, ++ protocol.NewKeyGenerator(), ++ ModelOptions{ReceiveSideReadOnly: true}, ++ ).(*model) ++ ctx, cancel := context.WithCancel(t.Context()) ++ go evLogger.Serve(ctx) ++ return &testModel{ ++ model: m, ++ evCancel: cancel, ++ stopped: make(chan struct{}), ++ t: t, ++ } ++} ++ ++func issue150RuntimeFile(t *testing.T, name string, data []byte, id protocol.ShortID, sequence int64) protocol.FileInfo { ++ t.Helper() ++ blockSize := protocol.BlockSize(int64(len(data))) ++ blocks, err := scanner.Blocks(t.Context(), bytes.NewReader(data), blockSize, int64(len(data)), nil) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return protocol.FileInfo{ ++ Name: name, ++ Type: protocol.FileInfoTypeFile, ++ Size: int64(len(data)), ++ ModifiedS: time.Now().Unix(), ++ Permissions: 0o644, ++ RawBlockSize: int32(blockSize), ++ Blocks: blocks, ++ Version: protocol.Vector{}.Update(id), ++ Sequence: sequence, ++ } ++} ++ ++func issue150ExpectNeed(t *testing.T, m *testModel, folder string, want int) { ++ t.Helper() ++ counts, err := m.NeedSize(folder, protocol.LocalDeviceID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if got := counts.TotalItems(); got != want { ++ t.Errorf("need items = %d, want %d (%v)", got, want, counts) ++ } ++} ++ ++func issue150WaitForBlockChange(t *testing.T, m *testModel, folder, name string, originalHash []byte) bool { ++ t.Helper() ++ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) ++ defer cancel() ++ for { ++ fi, ok, err := m.CurrentFolderFile(folder, name) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if ok && len(fi.Blocks) > 0 && !bytes.Equal(fi.Blocks[0].Hash, originalHash) { ++ return true ++ } ++ select { ++ case <-ctx.Done(): ++ return false ++ case <-time.After(5 * time.Millisecond): ++ } ++ } ++} ++ ++func issue150RecordedLogs() string { ++ var messages strings.Builder ++ for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) { ++ messages.WriteString(line.Message) ++ messages.WriteByte('\n') ++ } ++ return messages.String() ++} ++ ++func issue150RequireRuntimeSafetyError(t *testing.T, err error) { ++ t.Helper() ++ expected := config.ErrVaultSyncReceiveSideSafetyStop ++ if err == nil { ++ t.Fatalf("protected scan returned nil, want %q", expected.Error()) ++ } ++ if !errors.Is(err, expected) { ++ t.Errorf("protected scan error = %v, want canonical safety stop", err) ++ } ++ if got := err.Error(); got != expected.Error() { ++ t.Errorf("protected scan error = %q, want exact path-free %q", got, expected.Error()) ++ } ++} +diff --git a/lib/model/model.go b/lib/model/model.go +index 4c82a81..a6c4365 100644 +--- a/lib/model/model.go ++++ b/lib/model/model.go +@@ -141,6 +141,7 @@ type model struct { + sdb db.DB + protectedFiles []string + evLogger events.Logger ++ options ModelOptions + + // constant or concurrency safe fields + progressEmitter *ProgressEmitter +@@ -181,6 +182,12 @@ type model struct { + foldersRunning atomic.Int32 + } + ++// ModelOptions enables application-specific safety boundaries without ++// changing the default Syncthing runtime semantics. ++type ModelOptions struct { ++ ReceiveSideReadOnly bool ++} ++ + var _ config.Verifier = &model{} + + type folderFactory func(*model, *ignore.Matcher, config.FolderConfiguration, versioner.Versioner, events.Logger, *semaphore.Semaphore) service +@@ -213,6 +220,12 @@ var ( + // where it sends index information to connected peers and responds to requests + // for file data without altering the local folder in any way. + func NewModel(cfg config.Wrapper, id protocol.DeviceID, sdb db.DB, protectedFiles []string, evLogger events.Logger, keyGen *protocol.KeyGenerator) Model { ++ return NewModelWithOptions(cfg, id, sdb, protectedFiles, evLogger, keyGen, ModelOptions{}) ++} ++ ++// NewModelWithOptions creates a model with explicitly selected application ++// safety boundaries. The zero value retains the standard Syncthing behavior. ++func NewModelWithOptions(cfg config.Wrapper, id protocol.DeviceID, sdb db.DB, protectedFiles []string, evLogger events.Logger, keyGen *protocol.KeyGenerator, options ModelOptions) Model { + spec := svcutil.SpecWithDebugLogger() + m := &model{ + Supervisor: suture.New("model", spec), +@@ -223,6 +236,7 @@ func NewModel(cfg config.Wrapper, id protocol.DeviceID, sdb db.DB, protectedFile + sdb: sdb, + protectedFiles: protectedFiles, + evLogger: evLogger, ++ options: options, + + // constant or concurrency safe fields + progressEmitter: NewProgressEmitter(cfg, evLogger), +@@ -293,11 +307,23 @@ func (m *model) serve(ctx context.Context) error { + } + } + ++// WaitForStartup blocks until the initial configuration has been subscribed ++// and applied. VaultSync can invoke an exact one-shot configuration capability ++// as soon as App.Start returns; without this barrier, an immediate change can ++// precede Subscribe and skip the model's authorized cleanup semantics (#150). ++// This stays off the broad Model interface so ordinary Syncthing callers and ++// generated mocks retain their existing startup contract. ++func (m *model) WaitForStartup() { ++ <-m.started ++} ++ + func (m *model) initFolders(cfg config.Configuration) error { + clusterConfigDevices := make(deviceIDSet, len(cfg.Devices)) + for _, folderCfg := range cfg.Folders { + if folderCfg.Paused { +- folderCfg.CreateRoot() ++ if !m.receiveSideReadOnlyFolder(folderCfg) { ++ folderCfg.CreateRoot() ++ } + continue + } + err := m.newFolder(folderCfg, cfg.Options.CacheIgnoredFiles) +@@ -307,8 +333,10 @@ func (m *model) initFolders(cfg config.Configuration) error { + clusterConfigDevices.add(folderCfg.DeviceIDs()) + } + +- ignoredDevices := observedDeviceSet(m.cfg.IgnoredDevices()) +- m.cleanPending(cfg.DeviceMap(), cfg.FolderMap(), ignoredDevices, nil) ++ if !m.receiveSideReadOnlyConfiguration(cfg) { ++ ignoredDevices := observedDeviceSet(m.cfg.IgnoredDevices()) ++ m.cleanPending(cfg.DeviceMap(), cfg.FolderMap(), ignoredDevices, nil) ++ } + + m.sendClusterConfig(clusterConfigDevices.AsSlice()) + return nil +@@ -336,6 +364,11 @@ func (m *model) fatal(err error) { + + // Need to hold lock on m.mut when calling this. + func (m *model) addAndStartFolderLocked(cfg config.FolderConfiguration, cacheIgnoredFiles bool) { ++ if m.receiveSideReadOnlyFolder(cfg) { ++ m.addAndStartReceiveSideReadOnlyFolderLocked(cfg) ++ return ++ } ++ + ignores := ignore.New(cfg.Filesystem(), ignore.WithCache(cacheIgnoredFiles)) + if cfg.Type != config.FolderTypeReceiveEncrypted { + if err := ignores.Load(".stignore"); err != nil && !fs.IsNotExist(err) { +@@ -459,8 +492,8 @@ func (m *model) warnAboutOverwritingProtectedFiles(cfg config.FolderConfiguratio + } + + func (m *model) removeFolder(cfg config.FolderConfiguration) { +- slog.Info("Removing folder", cfg.LogAttr()) +- defer slog.Info("Removed folder", cfg.LogAttr()) ++ slog.Info("Removing folder", m.folderLogAttr(cfg)) ++ defer slog.Info("Removed folder", m.folderLogAttr(cfg)) + + m.mut.RLock() + wait := m.folderRunners.StopAndWaitChan(cfg.ID, 0) +@@ -550,11 +583,11 @@ func (m *model) restartFolder(from, to config.FolderConfiguration, cacheIgnoredF + + switch { + case to.Paused: +- slog.Info("Paused folder", to.LogAttr()) ++ slog.Info("Paused folder", m.folderLogAttr(to)) + case from.Paused: +- slog.Info("Unpaused folder", to.LogAttr()) ++ slog.Info("Unpaused folder", m.folderLogAttr(to)) + default: +- slog.Info("Restarted folder", to.LogAttr()) ++ slog.Info("Restarted folder", m.folderLogAttr(to)) + } + + return nil +@@ -1170,7 +1203,7 @@ func (m *model) handleIndex(conn protocol.Connection, folder string, fs []protoc + l.Debugf("%v (in): %s / %q: %d files", op, deviceID, folder, len(fs)) + + if cfg, ok := m.cfg.Folder(folder); !ok || !cfg.SharedWith(deviceID) { +- slog.Warn(`Operation for unexpected folder ID; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, slog.String("operation", op), cfg.LogAttr(), deviceID.LogAttr()) ++ slog.Warn(`Operation for unexpected folder ID; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, slog.String("operation", op), m.folderLogAttr(cfg), deviceID.LogAttr()) + return fmt.Errorf("%s: %w", folder, ErrFolderMissing) + } else if cfg.Paused { + l.Debugf("%v for paused folder (ID %q) sent from device %q.", op, folder, deviceID) +@@ -1241,11 +1274,11 @@ func (m *model) ClusterConfig(conn protocol.Connection, cm *protocol.ClusterConf + } + } + if info.remote.ID == protocol.EmptyDeviceID { +- slog.Warn("Device sent cluster-config without the device info for the remote", folder.LogAttr(), deviceID.LogAttr()) ++ slog.Warn("Device sent cluster-config without the device info for the remote", m.protocolFolderLogAttr(folder), deviceID.LogAttr()) + return errMissingRemoteInClusterConfig + } + if info.local.ID == protocol.EmptyDeviceID { +- slog.Warn("Device sent cluster-config without the device info for us locally", folder.LogAttr(), deviceID.LogAttr()) ++ slog.Warn("Device sent cluster-config without the device info for us locally", m.protocolFolderLogAttr(folder), deviceID.LogAttr()) + return errMissingLocalInClusterConfig + } + ccDeviceInfos[folder.ID] = info +@@ -1259,7 +1292,7 @@ func (m *model) ClusterConfig(conn protocol.Connection, cm *protocol.ClusterConf + } + + // Needs to happen outside of the mut, as can cause CommitConfiguration +- if deviceCfg.AutoAcceptFolders { ++ if deviceCfg.AutoAcceptFolders && !m.options.ReceiveSideReadOnly { + w, _ := m.cfg.Modify(func(cfg *config.Configuration) { + changedFcfg := make(map[string]config.FolderConfiguration) + haveFcfg := cfg.FolderMap() +@@ -1410,7 +1443,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi + if !ok { + indexHandlers.Remove(folder.ID) + if deviceCfg.IgnoredFolder(folder.ID) { +- slog.Info("Ignoring announced folder", folder.LogAttr(), deviceID.LogAttr()) ++ slog.Info("Ignoring announced folder", m.protocolFolderLogAttr(folder), deviceID.LogAttr()) + continue + } + delete(expiredPending, folder.ID) +@@ -1436,7 +1469,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi + "folderLabel": folder.Label, + "device": deviceID.String(), + }) +- slog.Warn(`Unexpected folder ID in ClusterConfig; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, folder.LogAttr(), deviceID.LogAttr()) ++ slog.Warn(`Unexpected folder ID in ClusterConfig; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, m.protocolFolderLogAttr(folder), deviceID.LogAttr()) + continue + } + +@@ -1463,14 +1496,14 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi + m.mut.Unlock() + const msg = "Failed to verify encryption consistency" + if sameError { +- slog.Debug(msg, cfg.LogAttr(), deviceID.LogAttr(), slogutil.Error(err)) ++ slog.Debug(msg, m.folderLogAttr(cfg), deviceID.LogAttr(), slogutil.Error(err)) + } else { + var rerr *redactedError + if errors.As(err, &rerr) { + err = rerr.redacted + } + m.evLogger.Log(events.Failure, err.Error()) +- slog.Error(msg, cfg.LogAttr(), deviceID.LogAttr(), slogutil.Error(err)) ++ slog.Error(msg, m.folderLogAttr(cfg), deviceID.LogAttr(), slogutil.Error(err)) + } + return tempIndexFolders, seenFolders, err + } +@@ -1497,7 +1530,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi + // Explicitly mark folders we offer, but the remote has not accepted + for folderID, cfg := range m.cfg.Folders() { + if _, seen := seenFolders[folderID]; !seen && cfg.SharedWith(deviceID) { +- l.Debugf("Remote device %v has not accepted sharing folder %s", deviceID.Short(), cfg.Description()) ++ l.Debugf("Remote device %v has not accepted sharing folder %s", deviceID.Short(), m.folderDescription(cfg)) + seenFolders[folderID] = remoteFolderNotSharing + } + } +@@ -1591,6 +1624,9 @@ func (m *model) ccCheckEncryption(fcfg config.FolderConfiguration, folderDevice + if !ok { + var err error + token, err = readEncryptionToken(fcfg) ++ if m.receiveSideReadOnlyFolder(fcfg) && err != nil { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++ } + if err != nil && !fs.IsNotExist(err) { + if rerr, ok := redactPathError(err); ok { + return rerr +@@ -1687,13 +1723,13 @@ func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm + } + + if fcfg.Type != config.FolderTypeReceiveEncrypted && device.EncryptionPasswordToken != nil { +- slog.Warn("Cannot share folder in untrusted mode with introduced device because it requires a password", folder.LogAttr(), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID)) ++ slog.Warn("Cannot share folder in untrusted mode with introduced device because it requires a password", m.folderLogAttr(fcfg), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID)) + continue + } + + // We don't yet share this folder with this device. Add the device + // to sharing list of the folder. +- slog.Info("Sharing folder vouched for by introducer", folder.LogAttr(), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID)) ++ slog.Info("Sharing folder vouched for by introducer", m.folderLogAttr(fcfg), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID)) + fcfg.Devices = append(fcfg.Devices, config.FolderDeviceConfiguration{ + DeviceID: device.ID, + IntroducedBy: introducerCfg.DeviceID, +@@ -1711,7 +1747,7 @@ func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm + } + + // handleDeintroductions handles removals of devices/shares that are removed by an introducer device +-func (*model) handleDeintroductions(introducerCfg config.DeviceConfiguration, foldersDevices folderDeviceSet, folders map[string]config.FolderConfiguration, devices map[protocol.DeviceID]config.DeviceConfiguration) (map[string]config.FolderConfiguration, map[protocol.DeviceID]config.DeviceConfiguration, bool) { ++func (m *model) handleDeintroductions(introducerCfg config.DeviceConfiguration, foldersDevices folderDeviceSet, folders map[string]config.FolderConfiguration, devices map[protocol.DeviceID]config.DeviceConfiguration) (map[string]config.FolderConfiguration, map[protocol.DeviceID]config.DeviceConfiguration, bool) { + if introducerCfg.SkipIntroductionRemovals { + return folders, devices, false + } +@@ -1730,7 +1766,7 @@ func (*model) handleDeintroductions(introducerCfg config.DeviceConfiguration, fo + // We could not find that folder shared on the + // introducer with the device that was introduced to us. + // We should follow and unshare as well. +- slog.Info("Unsharing folder as introducer no longer shares the folder with that device", folderCfg.LogAttr(), slog.Any("device", folderCfg.Devices[k].DeviceID), slog.Any("introducer", folderCfg.Devices[k].IntroducedBy)) ++ slog.Info("Unsharing folder as introducer no longer shares the folder with that device", m.folderLogAttr(folderCfg), slog.Any("device", folderCfg.Devices[k].DeviceID), slog.Any("introducer", folderCfg.Devices[k].IntroducedBy)) + folderCfg.Devices = append(folderCfg.Devices[:k], folderCfg.Devices[k+1:]...) + folders[folderID] = folderCfg + k-- +@@ -1975,38 +2011,80 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr + if !ok { + // The folder might be already unpaused in the config, but not yet + // in the model. +- l.Debugf("Request from %s for file %s in unstarted folder %q", deviceID.Short(), req.Name, req.Folder) ++ if cfg, cfgOK := m.cfg.Folder(req.Folder); cfgOK && m.receiveSideReadOnlyFolder(cfg) { ++ l.Debugf("Protected request for unstarted folder from %s", deviceID.Short()) ++ } else { ++ l.Debugf("Request from %s for file %s in unstarted folder %q", deviceID.Short(), req.Name, req.Folder) ++ } + return nil, protocol.ErrGeneric + } ++ protected := m.receiveSideReadOnlyFolder(folderCfg) + + if !folderCfg.SharedWith(deviceID) { +- slog.Warn("Request for file in unshared folder", slog.String("folder", req.Folder), deviceID.LogAttr(), slogutil.FilePath(req.Name)) ++ if protected { ++ slog.Warn("Protected request for unshared folder", slog.String("folder", req.Folder), deviceID.LogAttr()) ++ } else { ++ slog.Warn("Request for file in unshared folder", slog.String("folder", req.Folder), deviceID.LogAttr(), slogutil.FilePath(req.Name)) ++ } + return nil, protocol.ErrGeneric + } + if folderCfg.Paused { +- l.Debugf("Request from %s for file %s in paused folder %q", deviceID.Short(), req.Name, req.Folder) ++ if protected { ++ l.Debugf("Protected request for paused folder from %s", deviceID.Short()) ++ } else { ++ l.Debugf("Request from %s for file %s in paused folder %q", deviceID.Short(), req.Name, req.Folder) ++ } + return nil, protocol.ErrGeneric + } + + // Make sure the path is valid and in canonical form + if name, err := fs.Canonicalize(req.Name); err != nil { +- l.Debugf("Request from %s in folder %q for invalid filename %s", deviceID.Short(), req.Folder, req.Name) ++ if protected { ++ l.Debugf("Protected request from %s has an invalid filename", deviceID.Short()) ++ } else { ++ l.Debugf("Request from %s in folder %q for invalid filename %s", deviceID.Short(), req.Folder, req.Name) ++ } + return nil, protocol.ErrGeneric + } else { + req.Name = name + } + + if deviceID != protocol.LocalDeviceID { +- l.Debugf("%v REQ(in): %s: %q / %q o=%d s=%d t=%v", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size, req.FromTemporary) ++ if protected { ++ l.Debugf("%v protected REQ(in): %s folder=%q o=%d s=%d t=%v", m, deviceID.Short(), req.Folder, req.Offset, req.Size, req.FromTemporary) ++ } else { ++ l.Debugf("%v REQ(in): %s: %q / %q o=%d s=%d t=%v", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size, req.FromTemporary) ++ } + } + + if fs.IsInternal(req.Name) { +- l.Debugf("%v REQ(in) for internal file: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) for internal file: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) for internal file: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrInvalid + } + ++ if protected && folderCfg.Type != config.FolderTypeReceiveEncrypted { ++ if folderIgnores == nil { ++ l.Debugf("%v protected REQ(in) has no ignore policy: %s folder=%q", m, deviceID.Short(), req.Folder) ++ return nil, protocol.ErrInvalid ++ } ++ if err := folderIgnores.Load(".stignore"); err != nil && !fs.IsNotExist(err) { ++ l.Debugf("%v protected REQ(in) could not load ignore policy: %s folder=%q", m, deviceID.Short(), req.Folder) ++ return nil, protocol.ErrInvalid ++ } ++ } ++ if folderIgnores == nil { ++ return nil, protocol.ErrInvalid ++ } + if folderIgnores.Match(req.Name).IsIgnored() { +- l.Debugf("%v REQ(in) for ignored file: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) for ignored file: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) for ignored file: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrInvalid + } + +@@ -2033,7 +2111,11 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr + folderFs := folderCfg.Filesystem() + + if err := osutil.TraversesSymlink(folderFs, filepath.Dir(req.Name)); err != nil { +- l.Debugf("%v REQ(in) traversal check: %s - %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) traversal check failed: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) traversal check: %s - %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrNoSuchFile + } + +@@ -2045,7 +2127,11 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr + if info, err := folderFs.Lstat(tempFn); err != nil || !info.IsRegular() { + // Reject reads for anything that doesn't exist or is something + // other than a regular file. +- l.Debugf("%v REQ(in) failed stating temp file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) failed stating temp file: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) failed stating temp file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrNoSuchFile + } + _, err := readOffsetIntoBuf(folderFs, tempFn, req.Offset, res.data) +@@ -2059,14 +2145,22 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr + if info, err := folderFs.Lstat(req.Name); err != nil || !info.IsRegular() { + // Reject reads for anything that doesn't exist or is something + // other than a regular file. +- l.Debugf("%v REQ(in) failed stating file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) failed stating file: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) failed stating file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrNoSuchFile + } + + n, err := readOffsetIntoBuf(folderFs, req.Name, req.Offset, res.data) + switch { + case fs.IsNotExist(err): +- l.Debugf("%v REQ(in) file doesn't exist: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) file does not exist: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) file doesn't exist: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrNoSuchFile + case errors.Is(err, io.EOF): + // Read beyond end of file. This might indicate a problem, or it +@@ -2075,13 +2169,21 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr + // next step take care of it, by only hashing the part we actually + // managed to read. + case err != nil: +- l.Debugf("%v REQ(in) failed reading file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) failed reading file: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ l.Debugf("%v REQ(in) failed reading file (%v): %s: %q / %q o=%d s=%d", m, err, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrGeneric + } + + if folderCfg.Type != config.FolderTypeReceiveEncrypted && len(req.Hash) > 0 && !scanner.Validate(res.data[:n], req.Hash) { +- m.recheckFile(deviceID, req.Folder, req.Name, req.Offset, req.Hash) +- l.Debugf("%v REQ(in) failed validating data: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ if protected { ++ l.Debugf("%v protected REQ(in) failed validating data: %s folder=%q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Offset, req.Size) ++ } else { ++ m.recheckFile(deviceID, req.Folder, req.Name, req.Offset, req.Hash) ++ l.Debugf("%v REQ(in) failed validating data: %s: %q / %q o=%d s=%d", m, deviceID.Short(), req.Folder, req.Name, req.Offset, req.Size) ++ } + return nil, protocol.ErrNoSuchFile + } + +@@ -2183,6 +2285,9 @@ func (m *model) LoadIgnores(folder string) ([]string, []string, error) { + return nil, nil, fmt.Errorf("folder %s does not exist", folder) + } + } ++ if m.receiveSideReadOnlyFolder(cfg) { ++ return nil, nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + + if cfg.Type == config.FolderTypeReceiveEncrypted { + return nil, nil, nil +@@ -2215,7 +2320,6 @@ func (m *model) CurrentIgnores(folder string) ([]string, []string, error) { + if !cfgOk { + return nil, nil, fmt.Errorf("folder %s does not exist", folder) + } +- + if !ignoresOk { + // Empty ignore patterns + return []string{}, []string{}, nil +@@ -2229,6 +2333,9 @@ func (m *model) SetIgnores(folder string, content []string) error { + if !ok { + return fmt.Errorf("folder %s does not exist", cfg.Description()) + } ++ if m.receiveSideReadOnlyFolder(cfg) { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++ } + return m.setIgnores(cfg, content) + } + +@@ -2597,6 +2704,12 @@ func (m *model) generateClusterConfigRLocked(device protocol.DeviceID) (*protoco + } + + encryptionToken, hasEncryptionToken := m.folderEncryptionPasswordTokens[folderCfg.ID] ++ if folderCfg.Type == config.FolderTypeReceiveEncrypted && !hasEncryptionToken && m.receiveSideReadOnlyFolder(folderCfg) { ++ if token, err := readEncryptionToken(folderCfg); err == nil { ++ encryptionToken = token ++ hasEncryptionToken = true ++ } ++ } + if folderCfg.Type == config.FolderTypeReceiveEncrypted && !hasEncryptionToken { + // We haven't gotten a token for us yet and without one the other + // side can't validate us - pretend we don't have the folder yet. +@@ -2833,6 +2946,9 @@ func (m *model) RestoreFolderVersions(folder string, versions map[string]time.Ti + if err != nil { + return nil, err + } ++ if m.receiveSideReadOnlyFolder(fcfg) { ++ return nil, config.ErrVaultSyncReceiveSideSafetyStop ++ } + if ver == nil { + return nil, errNoVersioner + } +@@ -2929,6 +3045,9 @@ func (m *model) BringToFront(folder, file string) { + } + + func (m *model) ResetFolder(folder string) error { ++ if cfg, ok := m.cfg.Folder(folder); ok && m.receiveSideReadOnlyFolder(cfg) { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++ } + m.mut.Lock() + defer m.mut.Unlock() + _, ok := m.folderRunners.Get(folder) +@@ -2943,7 +3062,7 @@ func (m *model) String() string { + return fmt.Sprintf("model@%p", m) + } + +-func (*model) VerifyConfiguration(from, to config.Configuration) error { ++func (m *model) VerifyConfiguration(from, to config.Configuration) error { + toFolders := to.FolderMap() + for _, from := range from.Folders { + to, ok := toFolders[from.ID] +@@ -2956,7 +3075,13 @@ func (*model) VerifyConfiguration(from, to config.Configuration) error { + // will panic later when starting the folder. + for _, to := range to.Folders { + if to.Versioning.Type != "" { +- if _, err := versioner.New(to); err != nil { ++ var err error ++ if m.receiveSideReadOnlyFolder(to) { ++ _, err = versioner.NewVaultSyncInspectionOnly(to) ++ } else { ++ _, err = versioner.New(to) ++ } ++ if err != nil { + return err + } + } +@@ -2982,9 +3107,9 @@ func (m *model) CommitConfiguration(from, to config.Configuration) bool { + if _, ok := fromFolders[folderID]; !ok { + // A folder was added. + if cfg.Paused { +- slog.Info("Paused folder", cfg.LogAttr()) ++ slog.Info("Paused folder", m.folderLogAttr(cfg)) + } else { +- slog.Info("Adding folder", cfg.LogAttr()) ++ slog.Info("Adding folder", m.folderLogAttr(cfg)) + if err := m.newFolder(cfg, to.Options.CacheIgnoredFiles); err != nil { + m.fatal(err) + return true +diff --git a/lib/syncthing/internals.go b/lib/syncthing/internals.go +index 2700487..b6f0c4c 100644 +--- a/lib/syncthing/internals.go ++++ b/lib/syncthing/internals.go +@@ -36,6 +36,10 @@ func (m *Internals) FolderState(folderID string) (string, time.Time, error) { + return m.model.State(folderID) + } + ++func (m *Internals) FolderErrors(folderID string) ([]model.FileError, error) { ++ return m.model.FolderErrors(folderID) ++} ++ + func (m *Internals) Ignores(folderID string) ([]string, []string, error) { + return m.model.CurrentIgnores(folderID) + } +diff --git a/lib/syncthing/issue150_database_preflight_test.go b/lib/syncthing/issue150_database_preflight_test.go +new file mode 100644 +index 0000000..ef79632 +--- /dev/null ++++ b/lib/syncthing/issue150_database_preflight_test.go +@@ -0,0 +1,911 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package syncthing ++ ++import ( ++ "bytes" ++ "database/sql" ++ "errors" ++ "fmt" ++ "io" ++ "os" ++ "os/exec" ++ "path/filepath" ++ "reflect" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/syncthing/syncthing/lib/config" ++ "github.com/syncthing/syncthing/lib/events" ++ "github.com/syncthing/syncthing/lib/protocol" ++ "github.com/syncthing/syncthing/lib/tlsutil" ++ _ "modernc.org/sqlite" ++) ++ ++const ( ++ issue150WALChildModeEnvironment = "VAULTSYNC_ISSUE150_WAL_CHILD" ++ issue150WALPathEnvironment = "VAULTSYNC_ISSUE150_WAL_PATH" ++) ++ ++func TestIssue150ReceiveSideDatabaseSafetyStopReusesCanonicalConfigError(t *testing.T) { ++ if ErrReceiveSideReadOnlySafetyStop != config.ErrVaultSyncReceiveSideSafetyStop { ++ t.Fatal("database safety stop does not reuse the canonical configuration error") ++ } ++} ++ ++func TestIssue150ConfigStartupPreflightRunsBeforeUpgradeSave(t *testing.T) { ++ home := t.TempDir() ++ configPath := filepath.Join(home, "config.xml") ++ certificate, err := tlsutil.NewCertificate( ++ filepath.Join(home, "cert.pem"), ++ filepath.Join(home, "key.pem"), ++ "syncthing", ++ 1, ++ false, ++ ) ++ if err != nil { ++ t.Fatalf("create identity: %v", err) ++ } ++ myID := protocol.NewDeviceID(certificate.Certificate[0]) ++ wrapper := config.Wrap(configPath, config.New(myID), myID, events.NoopLogger) ++ if err := wrapper.Save(); err != nil { ++ t.Fatalf("save config: %v", err) ++ } ++ ++ before, err := os.ReadFile(configPath) ++ if err != nil { ++ t.Fatalf("read config: %v", err) ++ } ++ currentVersion := []byte(fmt.Sprintf(`version="%d"`, config.CurrentVersion)) ++ previousVersion := config.CurrentVersion - 1 ++ if got := bytes.Count(before, currentVersion); got != 1 { ++ t.Fatalf("current config version occurrences = %d, want 1", got) ++ } ++ before = bytes.Replace( ++ before, ++ currentVersion, ++ []byte(fmt.Sprintf(`version="%d"`, previousVersion)), ++ 1, ++ ) ++ if err := os.WriteFile(configPath, before, 0o600); err != nil { ++ t.Fatalf("write previous-version config: %v", err) ++ } ++ ++ preflightErr := errors.New("issue150-preflight-stop") ++ preflightCalls := 0 ++ loaded, err := LoadConfigAtStartupWithPreflight( ++ configPath, ++ certificate, ++ events.NoopLogger, ++ false, ++ true, ++ func(config.Wrapper) error { ++ preflightCalls++ ++ return preflightErr ++ }, ++ ) ++ if !errors.Is(err, preflightErr) { ++ t.Fatalf("load error = %v, want preflight error", err) ++ } ++ if loaded != nil { ++ t.Fatal("failed preflight returned a configuration") ++ } ++ if preflightCalls != 1 { ++ t.Fatalf("preflight calls = %d, want 1", preflightCalls) ++ } ++ ++ after, err := os.ReadFile(configPath) ++ if err != nil { ++ t.Fatalf("read config after preflight: %v", err) ++ } ++ if !bytes.Equal(after, before) { ++ t.Fatal("failed startup preflight rewrote config") ++ } ++ archivePath := configPath + fmt.Sprintf(".v%d", previousVersion) ++ if _, err := os.Stat(archivePath); !os.IsNotExist(err) { ++ t.Fatalf("failed startup preflight created archive: %v", err) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightRejectsFutureCaseFoldAliasesBeforeMutation(t *testing.T) { ++ testCases := []struct { ++ name string ++ rowOrder []string ++ policyOrder []issue150FolderPolicy ++ }{ ++ { ++ name: "Issue150/rows-alpha-beta/policy-alpha-beta/protected-protected", ++ rowOrder: []string{"alpha", "beta"}, ++ policyOrder: []issue150FolderPolicy{ ++ {id: "alpha", folderType: config.FolderTypeReceiveOnly}, ++ {id: "beta", folderType: config.FolderTypeReceiveEncrypted}, ++ }, ++ }, ++ { ++ name: "Issue150/rows-beta-alpha/policy-beta-alpha/protected-protected", ++ rowOrder: []string{"beta", "alpha"}, ++ policyOrder: []issue150FolderPolicy{ ++ {id: "beta", folderType: config.FolderTypeReceiveEncrypted}, ++ {id: "alpha", folderType: config.FolderTypeReceiveOnly}, ++ }, ++ }, ++ { ++ name: "Issue150/rows-alpha-beta/policy-beta-alpha/protected-sendonly", ++ rowOrder: []string{"alpha", "beta"}, ++ policyOrder: []issue150FolderPolicy{ ++ {id: "beta", folderType: config.FolderTypeSendOnly}, ++ {id: "alpha", folderType: config.FolderTypeReceiveOnly}, ++ }, ++ }, ++ { ++ name: "Issue150/rows-beta-alpha/policy-alpha-beta/sendonly-protected", ++ rowOrder: []string{"beta", "alpha"}, ++ policyOrder: []issue150FolderPolicy{ ++ {id: "alpha", folderType: config.FolderTypeSendOnly}, ++ {id: "beta", folderType: config.FolderTypeReceiveEncrypted}, ++ }, ++ }, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, testCase.rowOrder) ++ first := fixture.folders[testCase.rowOrder[0]] ++ second := fixture.folders[testCase.rowOrder[1]] ++ lowerName := fmt.Sprintf("folder.%04x-abcdefgh.db", first.idx) ++ upperName := strings.ToUpper(lowerName) ++ ++ issue150SetRegisteredDatabaseName(t, fixture, first.id, lowerName) ++ issue150SetRegisteredDatabaseName(t, fixture, second.id, upperName) ++ for _, folder := range []issue150DatabaseFolder{first, second} { ++ issue150RemoveDatabaseAndSidecars(t, folder.path) ++ } ++ for _, name := range []string{lowerName, upperName} { ++ if _, err := os.Lstat(filepath.Join(fixture.databasePath, name)); !os.IsNotExist(err) { ++ t.Fatalf("future colliding database unexpectedly exists: %v", err) ++ } ++ } ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, testCase.policyOrder)), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture, lowerName, upperName) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightRejectsNoncanonicalRegisteredNamesBeforeMutation(t *testing.T) { ++ testCases := []struct { ++ name string ++ dbName func(*issue150DatabaseFixture, issue150DatabaseFolder) string ++ }{ ++ { ++ name: "Issue150/absolute-existing-folder-database", ++ dbName: func(_ *issue150DatabaseFixture, folder issue150DatabaseFolder) string { ++ return folder.path ++ }, ++ }, ++ { ++ name: "Issue150/parent-traversal-existing-folder-database", ++ dbName: func(fixture *issue150DatabaseFixture, folder issue150DatabaseFolder) string { ++ return filepath.Join("..", filepath.Base(fixture.databasePath), folder.name) ++ }, ++ }, ++ { ++ name: "Issue150/noncanonical-case", ++ dbName: func(_ *issue150DatabaseFixture, folder issue150DatabaseFolder) string { ++ return strings.ToUpper(folder.name) ++ }, ++ }, ++ { ++ name: "Issue150/non-ascii", ++ dbName: func(_ *issue150DatabaseFixture, folder issue150DatabaseFolder) string { ++ return fmt.Sprintf("folder.%04x-abcdefgé.db", folder.idx) ++ }, ++ }, ++ { ++ name: "Issue150/bad-structure", ++ dbName: func(_ *issue150DatabaseFixture, folder issue150DatabaseFolder) string { ++ return fmt.Sprintf("folder.%04x-abcdefg.db", folder.idx) ++ }, ++ }, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected"}) ++ folder := fixture.folders["protected"] ++ dbName := testCase.dbName(fixture, folder) ++ issue150SetRegisteredDatabaseName(t, fixture, folder.id, dbName) ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: folder.id, ++ folderType: config.FolderTypeReceiveOnly, ++ }})), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture, dbName) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightRejectsExistingPhysicalAliasesBeforeMutation(t *testing.T) { ++ testCases := []struct { ++ name string ++ setup func(*testing.T, *issue150DatabaseFixture) ++ }{ ++ { ++ name: "Issue150/symlink-to-registered-folder-database", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ReplaceWithSymlink(t, fixture.folders["protected"].path, fixture.folders["other"].path) ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-registered-folder-database", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ReplaceWithHardlink(t, fixture.folders["protected"].path, fixture.folders["other"].path) ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-main-database", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ReplaceWithHardlink(t, fixture.folders["protected"].path, fixture.mainPath) ++ }, ++ }, ++ { ++ name: "Issue150/symlink-to-orphan-folder-database", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ orphanPath := filepath.Join(fixture.databasePath, "folder.7fff-orphaned.db") ++ issue150CopyFile(t, fixture.folders["other"].path, orphanPath) ++ issue150ReplaceWithSymlink(t, fixture.folders["protected"].path, orphanPath) ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-orphan-folder-database", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ orphanPath := filepath.Join(fixture.databasePath, "folder.7fff-orphaned.db") ++ issue150CopyFile(t, fixture.folders["other"].path, orphanPath) ++ issue150ReplaceWithHardlink(t, fixture.folders["protected"].path, orphanPath) ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-main-wal", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ReplaceWithReservedHardlink(t, fixture.folders["protected"].path, fixture.mainPath+"-wal") ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-main-shm", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ReplaceWithReservedHardlink(t, fixture.folders["protected"].path, fixture.mainPath+"-shm") ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-folder-wal", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ folderPath := fixture.folders["protected"].path ++ issue150ReplaceWithReservedHardlink(t, folderPath, folderPath+"-wal") ++ }, ++ }, ++ { ++ name: "Issue150/hardlink-to-folder-shm", ++ setup: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ folderPath := fixture.folders["protected"].path ++ issue150ReplaceWithReservedHardlink(t, folderPath, folderPath+"-shm") ++ }, ++ }, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected", "other"}) ++ testCase.setup(t, fixture) ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{ ++ {id: "protected", folderType: config.FolderTypeReceiveOnly}, ++ {id: "other", folderType: config.FolderTypeSendOnly}, ++ })), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightRejectsUncheckpointedAppOwnedWALWithoutMutation(t *testing.T) { ++ if os.Getenv(issue150WALChildModeEnvironment) == "1" { ++ issue150LeaveCommittedWALForProcessExit(t, os.Getenv(issue150WALPathEnvironment)) ++ os.Exit(0) ++ } ++ ++ testCases := []struct { ++ name string ++ targetPath func(*issue150DatabaseFixture) string ++ }{ ++ { ++ name: "Issue150/main-database-wal", ++ targetPath: func(fixture *issue150DatabaseFixture) string { ++ return fixture.mainPath ++ }, ++ }, ++ { ++ name: "Issue150/folder-database-wal", ++ targetPath: func(fixture *issue150DatabaseFixture) string { ++ return fixture.folders["protected"].path ++ }, ++ }, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected"}) ++ targetPath := testCase.targetPath(fixture) ++ command := exec.Command(os.Args[0], "-test.run=^TestIssue150ReceiveSideDatabasePreflightRejectsUncheckpointedAppOwnedWALWithoutMutation$") ++ command.Env = append( ++ os.Environ(), ++ issue150WALChildModeEnvironment+"=1", ++ issue150WALPathEnvironment+"="+targetPath, ++ ) ++ if output, err := command.CombinedOutput(); err != nil { ++ t.Fatalf("create app-owned crash WAL: %v (%s)", err, bytes.TrimSpace(output)) ++ } ++ walInfo, err := os.Stat(targetPath + "-wal") ++ if err != nil || walInfo.Size() == 0 { ++ t.Fatalf("uncheckpointed WAL fixture is unavailable: %v", err) ++ } ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: "protected", ++ folderType: config.FolderTypeReceiveOnly, ++ }})), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightRejectsRecognizableDatabaseDeviationsBeforeMutation(t *testing.T) { ++ testCases := []struct { ++ name string ++ mutate func(*testing.T, *issue150DatabaseFixture) ++ }{ ++ { ++ name: "Issue150/main-schema", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150SetPreviousSchemaVersion(t, fixture.mainPath) ++ }, ++ }, ++ { ++ name: "Issue150/main-application-id", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ExecSQLite(t, fixture.mainPath, "PRAGMA application_id = 305419896") ++ }, ++ }, ++ { ++ name: "Issue150/main-recognizable-truncation", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150TruncateDatabase(t, fixture.mainPath) ++ }, ++ }, ++ { ++ name: "Issue150/folder-schema", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150SetPreviousSchemaVersion(t, fixture.folders["protected"].path) ++ }, ++ }, ++ { ++ name: "Issue150/folder-application-id", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ExecSQLite(t, fixture.folders["protected"].path, "PRAGMA application_id = 305419896") ++ }, ++ }, ++ { ++ name: "Issue150/folder-recognizable-truncation", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150TruncateDatabase(t, fixture.folders["protected"].path) ++ }, ++ }, ++ { ++ name: "Issue150/folder-identity", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150ExecSQLite(t, fixture.folders["protected"].path, "UPDATE kv SET value = ? WHERE key = 'folderID'", []byte("different-folder")) ++ }, ++ }, ++ { ++ name: "Issue150/folder-foreign-key-integrity", ++ mutate: func(t *testing.T, fixture *issue150DatabaseFixture) { ++ issue150WithSQLite(t, fixture.folders["protected"].path, func(database *sql.DB) { ++ database.SetMaxOpenConns(1) ++ if _, err := database.Exec("PRAGMA foreign_keys = OFF"); err != nil { ++ t.Fatalf("disable fixture foreign keys: %v", err) ++ } ++ if _, err := database.Exec("INSERT INTO indexids (device_idx, index_id, sequence) VALUES (987654321, 'issue150', 0)"); err != nil { ++ t.Fatalf("create fixture foreign-key deviation: %v", err) ++ } ++ }) ++ }, ++ }, ++ } ++ ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected"}) ++ testCase.mutate(t, fixture) ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: "protected", ++ folderType: config.FolderTypeReceiveOnly, ++ }})), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++ }) ++ } ++} ++ ++func TestIssue150ReceiveSideDatabasePreflightNormalizesLateMutatingMainOpenFailure(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected"}) ++ if err := os.Chmod(fixture.mainPath, 0o400); err != nil { ++ t.Fatalf("make main database read-only: %v", err) ++ } ++ if err := os.Chmod(fixture.databasePath, 0o500); err != nil { ++ t.Fatalf("make database directory read-only: %v", err) ++ } ++ t.Cleanup(func() { ++ _ = os.Chmod(fixture.databasePath, 0o700) ++ _ = os.Chmod(fixture.mainPath, 0o600) ++ }) ++ ++ before := issue150SnapshotDatabaseTree(t, fixture.databasePath) ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: "protected", ++ folderType: config.FolderTypeReceiveOnly, ++ }})), ++ ) ++ issue150CloseUnexpectedDatabase(t, database) ++ issue150RequirePathFreeSafetyStop(t, err, fixture) ++ issue150RequireUnchangedDatabaseTree(t, fixture.databasePath, before) ++} ++ ++func TestIssue150ProtectedDatabaseOpenDoesNotCleanOrphanDatabaseArtifacts(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"protected"}) ++ orphanPath := filepath.Join(fixture.databasePath, "folder.7fff-orphaned.db") ++ issue150CopyFile(t, fixture.folders["protected"].path, orphanPath) ++ before, err := os.ReadFile(orphanPath) ++ if err != nil { ++ t.Fatalf("read orphan fixture before protected open: %v", err) ++ } ++ ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: "protected", ++ folderType: config.FolderTypeReceiveOnly, ++ }})), ++ ) ++ if err != nil { ++ t.Fatalf("protected database open failed: %v", err) ++ } ++ if err := database.Close(); err != nil { ++ t.Fatalf("close protected database: %v", err) ++ } ++ after, err := os.ReadFile(orphanPath) ++ if err != nil { ++ t.Fatalf("read orphan fixture after protected open: %v", err) ++ } ++ if !bytes.Equal(after, before) { ++ t.Fatal("protected database open changed an orphan database artifact") ++ } ++} ++ ++func TestIssue150PureSendOnlyDatabaseOpenKeepsExistingWriteSemantics(t *testing.T) { ++ fixture := issue150NewDatabaseFixture(t, []string{"sendonly"}) ++ ++ database, err := OpenDatabase( ++ fixture.databasePath, ++ 24*time.Hour, ++ WithReceiveSideReadOnlyConfig(issue150Config(t, []issue150FolderPolicy{{ ++ id: "sendonly", ++ folderType: config.FolderTypeSendOnly, ++ }})), ++ ) ++ if err != nil { ++ t.Fatalf("pure SendOnly database open failed: %v", err) ++ } ++ if err := database.Update("sendonly", protocol.LocalDeviceID, []protocol.FileInfo{{ ++ Name: "sendonly-positive-control", ++ Sequence: 2, ++ }}); err != nil { ++ _ = database.Close() ++ t.Fatalf("pure SendOnly database update failed: %v", err) ++ } ++ if err := database.Close(); err != nil { ++ t.Fatalf("close pure SendOnly database: %v", err) ++ } ++} ++ ++type issue150FolderPolicy struct { ++ id string ++ folderType config.FolderType ++} ++ ++type issue150DatabaseFolder struct { ++ id string ++ idx int64 ++ name string ++ path string ++} ++ ++type issue150DatabaseFixture struct { ++ root string ++ databasePath string ++ mainPath string ++ folders map[string]issue150DatabaseFolder ++} ++ ++func issue150NewDatabaseFixture(t *testing.T, rowOrder []string) *issue150DatabaseFixture { ++ t.Helper() ++ root := t.TempDir() ++ databasePath := filepath.Join(root, "data") ++ database, err := OpenDatabase(databasePath, 24*time.Hour) ++ if err != nil { ++ t.Fatalf("open fixture database: %v", err) ++ } ++ for idx, folderID := range rowOrder { ++ if err := database.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{{ ++ Name: fmt.Sprintf("seed-%d", idx), ++ Sequence: int64(idx + 1), ++ }}); err != nil { ++ _ = database.Close() ++ t.Fatalf("seed fixture folder: %v", err) ++ } ++ } ++ if err := database.Close(); err != nil { ++ t.Fatalf("close fixture database: %v", err) ++ } ++ ++ fixture := &issue150DatabaseFixture{ ++ root: root, ++ databasePath: databasePath, ++ mainPath: filepath.Join(databasePath, "main.db"), ++ folders: make(map[string]issue150DatabaseFolder, len(rowOrder)), ++ } ++ issue150WithSQLite(t, fixture.mainPath, func(database *sql.DB) { ++ rows, err := database.Query("SELECT idx, folder_id, database_name FROM folders") ++ if err != nil { ++ t.Fatalf("query fixture folder registrations: %v", err) ++ } ++ defer rows.Close() ++ for rows.Next() { ++ var folder issue150DatabaseFolder ++ if err := rows.Scan(&folder.idx, &folder.id, &folder.name); err != nil { ++ t.Fatalf("scan fixture folder registration: %v", err) ++ } ++ folder.path = filepath.Join(databasePath, folder.name) ++ fixture.folders[folder.id] = folder ++ } ++ if err := rows.Err(); err != nil { ++ t.Fatalf("iterate fixture folder registrations: %v", err) ++ } ++ }) ++ if len(fixture.folders) != len(rowOrder) { ++ t.Fatalf("fixture folder registrations = %d, want %d", len(fixture.folders), len(rowOrder)) ++ } ++ return fixture ++} ++ ++func issue150Config(t *testing.T, policies []issue150FolderPolicy) config.Wrapper { ++ t.Helper() ++ folders := make([]config.FolderConfiguration, 0, len(policies)) ++ for _, policy := range policies { ++ folders = append(folders, config.FolderConfiguration{ ++ ID: policy.id, ++ Path: filepath.Join(t.TempDir(), "vault"), ++ Type: policy.folderType, ++ }) ++ } ++ return config.Wrap( ++ filepath.Join(t.TempDir(), "config.xml"), ++ config.Configuration{Folders: folders}, ++ protocol.LocalDeviceID, ++ events.NoopLogger, ++ ) ++} ++ ++func issue150SetRegisteredDatabaseName(t *testing.T, fixture *issue150DatabaseFixture, folderID, databaseName string) { ++ t.Helper() ++ issue150ExecSQLite(t, fixture.mainPath, "UPDATE folders SET database_name = ? WHERE folder_id = ?", databaseName, folderID) ++} ++ ++func issue150RemoveDatabaseAndSidecars(t *testing.T, path string) { ++ t.Helper() ++ for _, candidate := range []string{path, path + "-wal", path + "-shm"} { ++ if err := os.Remove(candidate); err != nil && !os.IsNotExist(err) { ++ t.Fatalf("remove fixture database entry: %v", err) ++ } ++ } ++} ++ ++func issue150ReplaceWithSymlink(t *testing.T, path, target string) { ++ t.Helper() ++ issue150RemoveDatabaseAndSidecars(t, path) ++ if err := os.Symlink(filepath.Base(target), path); err != nil { ++ t.Fatalf("create fixture symlink: %v", err) ++ } ++} ++ ++func issue150ReplaceWithHardlink(t *testing.T, path, target string) { ++ t.Helper() ++ issue150RemoveDatabaseAndSidecars(t, path) ++ if err := os.Link(target, path); err != nil { ++ t.Fatalf("create fixture hardlink: %v", err) ++ } ++} ++ ++func issue150ReplaceWithReservedHardlink(t *testing.T, path, reservedPath string) { ++ t.Helper() ++ if err := os.Remove(reservedPath); err != nil && !os.IsNotExist(err) { ++ t.Fatalf("remove stale fixture sidecar: %v", err) ++ } ++ if err := os.Link(path, reservedPath); err != nil { ++ t.Fatalf("create fixture reserved hardlink: %v", err) ++ } ++ if err := os.Remove(path); err != nil { ++ t.Fatalf("remove registered fixture database: %v", err) ++ } ++ if err := os.Link(reservedPath, path); err != nil { ++ t.Fatalf("link registered fixture database to reserved entry: %v", err) ++ } ++} ++ ++func issue150CopyFile(t *testing.T, source, target string) { ++ t.Helper() ++ input, err := os.Open(source) ++ if err != nil { ++ t.Fatalf("open fixture copy source: %v", err) ++ } ++ defer input.Close() ++ output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) ++ if err != nil { ++ t.Fatalf("create fixture copy target: %v", err) ++ } ++ if _, err := io.Copy(output, input); err != nil { ++ _ = output.Close() ++ t.Fatalf("copy fixture database: %v", err) ++ } ++ if err := output.Close(); err != nil { ++ t.Fatalf("close fixture copy target: %v", err) ++ } ++} ++ ++func issue150TruncateDatabase(t *testing.T, path string) { ++ t.Helper() ++ if err := os.Truncate(path, 128); err != nil { ++ t.Fatalf("truncate fixture database: %v", err) ++ } ++} ++ ++func issue150LeaveCommittedWALForProcessExit(t *testing.T, path string) { ++ t.Helper() ++ if path == "" { ++ t.Fatal("WAL child database path is empty") ++ } ++ database, err := sql.Open("sqlite", path) ++ if err != nil { ++ t.Fatalf("open WAL child database: %v", err) ++ } ++ database.SetMaxOpenConns(1) ++ var journalMode string ++ if err := database.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil || journalMode != "wal" { ++ t.Fatalf("enable WAL child journal: mode=%q error=%v", journalMode, err) ++ } ++ if _, err := database.Exec("PRAGMA wal_autocheckpoint = 0"); err != nil { ++ t.Fatalf("disable WAL child autocheckpoint: %v", err) ++ } ++ if _, err := database.Exec("INSERT OR REPLACE INTO kv (key, value) VALUES ('issue150-app-owned-crash-state', ?)", []byte{1}); err != nil { ++ t.Fatalf("commit WAL child state: %v", err) ++ } ++ if info, err := os.Stat(path + "-wal"); err != nil || info.Size() == 0 { ++ t.Fatalf("WAL child did not create a journal: %v", err) ++ } ++} ++ ++func issue150SetPreviousSchemaVersion(t *testing.T, path string) { ++ t.Helper() ++ currentVersion := issue150SchemaVersion(t, path) ++ if currentVersion < 2 { ++ t.Fatalf("fixture schema version = %d, need a previous version", currentVersion) ++ } ++ issue150SetSchemaVersion(t, path, currentVersion-1) ++} ++ ++func issue150SchemaVersion(t *testing.T, path string) int { ++ t.Helper() ++ var version int ++ issue150WithSQLite(t, path, func(database *sql.DB) { ++ if err := database.QueryRow("SELECT MAX(schema_version) FROM schemamigrations").Scan(&version); err != nil { ++ t.Fatalf("read fixture schema version: %v", err) ++ } ++ }) ++ return version ++} ++ ++func issue150SetSchemaVersion(t *testing.T, path string, version int) { ++ t.Helper() ++ issue150WithSQLite(t, path, func(database *sql.DB) { ++ transaction, err := database.Begin() ++ if err != nil { ++ t.Fatalf("begin fixture schema change: %v", err) ++ } ++ defer transaction.Rollback() ++ if _, err := transaction.Exec("DELETE FROM schemamigrations"); err != nil { ++ t.Fatalf("clear fixture schema version: %v", err) ++ } ++ if _, err := transaction.Exec( ++ "INSERT INTO schemamigrations (schema_version, applied_at, syncthing_version) VALUES (?, 1, 'issue150')", ++ version, ++ ); err != nil { ++ t.Fatalf("set fixture schema version: %v", err) ++ } ++ if err := transaction.Commit(); err != nil { ++ t.Fatalf("commit fixture schema change: %v", err) ++ } ++ }) ++} ++ ++func issue150ExecSQLite(t *testing.T, path, query string, args ...any) { ++ t.Helper() ++ issue150WithSQLite(t, path, func(database *sql.DB) { ++ if _, err := database.Exec(query, args...); err != nil { ++ t.Fatalf("change fixture database: %v", err) ++ } ++ }) ++} ++ ++func issue150WithSQLite(t *testing.T, path string, fn func(*sql.DB)) { ++ t.Helper() ++ database, err := sql.Open("sqlite", path) ++ if err != nil { ++ t.Fatalf("open raw fixture database: %v", err) ++ } ++ database.SetMaxOpenConns(1) ++ if err := database.Ping(); err != nil { ++ _ = database.Close() ++ t.Fatalf("ping raw fixture database: %v", err) ++ } ++ fn(database) ++ if err := database.Close(); err != nil { ++ t.Fatalf("close raw fixture database: %v", err) ++ } ++} ++ ++type issue150DatabaseTreeEntry struct { ++ mode os.FileMode ++ contents []byte ++ linkTarget string ++} ++ ++func issue150SnapshotDatabaseTree(t *testing.T, root string) map[string]issue150DatabaseTreeEntry { ++ t.Helper() ++ snapshot := make(map[string]issue150DatabaseTreeEntry) ++ if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { ++ if err != nil { ++ return err ++ } ++ if path == root { ++ return nil ++ } ++ relative, err := filepath.Rel(root, path) ++ if err != nil { ++ return err ++ } ++ entry := issue150DatabaseTreeEntry{mode: info.Mode()} ++ switch { ++ case info.Mode().IsRegular(): ++ contents, err := os.ReadFile(path) ++ if err != nil { ++ return err ++ } ++ entry.contents = bytes.Clone(contents) ++ case info.Mode()&os.ModeSymlink != 0: ++ target, err := os.Readlink(path) ++ if err != nil { ++ return err ++ } ++ entry.linkTarget = target ++ } ++ snapshot[relative] = entry ++ return nil ++ }); err != nil { ++ t.Fatalf("snapshot fixture database tree: %v", err) ++ } ++ return snapshot ++} ++ ++func issue150RequireUnchangedDatabaseTree(t *testing.T, root string, before map[string]issue150DatabaseTreeEntry) { ++ t.Helper() ++ after := issue150SnapshotDatabaseTree(t, root) ++ if reflect.DeepEqual(after, before) { ++ return ++ } ++ for name, beforeEntry := range before { ++ afterEntry, ok := after[name] ++ if !ok || !reflect.DeepEqual(afterEntry, beforeEntry) { ++ t.Logf("database tree entry %q changed", name) ++ } ++ } ++ for name := range after { ++ if _, ok := before[name]; !ok { ++ t.Logf("database tree entry %q was created", name) ++ } ++ } ++ t.Fatal("blocked database open changed the database tree") ++} ++ ++func issue150RequirePathFreeSafetyStop(t *testing.T, err error, fixture *issue150DatabaseFixture, extraForbidden ...string) { ++ t.Helper() ++ if err == nil { ++ t.Fatal("database open succeeded, want receive-side safety stop") ++ } ++ if !errors.Is(err, ErrReceiveSideReadOnlySafetyStop) { ++ t.Fatalf("database open error does not match stable receive-side safety stop: %v", err) ++ } ++ if got, want := err.Error(), ErrReceiveSideReadOnlySafetyStop.Error(); got != want { ++ t.Fatalf("database open error = %q, want exact stable error %q", got, want) ++ } ++ forbidden := []string{fixture.root, fixture.databasePath, fixture.mainPath} ++ for _, folder := range fixture.folders { ++ forbidden = append(forbidden, folder.id, folder.name, folder.path) ++ } ++ forbidden = append(forbidden, extraForbidden...) ++ for _, value := range forbidden { ++ if value != "" && strings.Contains(err.Error(), value) { ++ t.Fatal("database open error leaked fixture identity") ++ } ++ } ++} ++ ++func issue150CloseUnexpectedDatabase(t *testing.T, database interface{ Close() error }) { ++ t.Helper() ++ if database == nil { ++ return ++ } ++ if err := database.Close(); err != nil { ++ t.Errorf("close unexpectedly opened database: %v", err) ++ } ++} +diff --git a/lib/syncthing/issue150_runtime_privacy_test.go b/lib/syncthing/issue150_runtime_privacy_test.go +new file mode 100644 +index 0000000..13fd1c1 +--- /dev/null ++++ b/lib/syncthing/issue150_runtime_privacy_test.go +@@ -0,0 +1,176 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package syncthing ++ ++import ( ++ "errors" ++ "path/filepath" ++ "strings" ++ "sync" ++ "testing" ++ "time" ++ ++ "github.com/thejerf/suture/v4" ++ ++ "github.com/syncthing/syncthing/internal/db" ++ "github.com/syncthing/syncthing/internal/db/sqlite" ++ "github.com/syncthing/syncthing/internal/slogutil" ++ "github.com/syncthing/syncthing/lib/config" ++ "github.com/syncthing/syncthing/lib/events" ++ "github.com/syncthing/syncthing/lib/protocol" ++ "github.com/syncthing/syncthing/lib/svcutil" ++ "github.com/syncthing/syncthing/lib/tlsutil" ++) ++ ++func TestIssue150ProtectedAppStartupSkipsGlobalMutationServices(t *testing.T) { ++ app, cfg, spy := issue150RuntimeApp(t, config.FolderTypeReceiveOnly) ++ app.mainService = suture.New("issue150-runtime", svcutil.SpecWithDebugLogger()) ++ slogutil.GlobalRecorder.Clear() ++ if err := app.startup(); err != nil { ++ t.Fatal(err) ++ } ++ ++ for _, call := range []string{"Service", "ListFolders", "DropFolder", "DropAllIndexIDs", "GetKV", "PutKV", "DeleteKV"} { ++ if got := spy.count(call); got != 0 { ++ t.Errorf("protected startup called mutating or migration DB surface %s %d times", call, got) ++ } ++ } ++ ++ if err := <-app.StartMaintenance(); !errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { ++ t.Errorf("maintenance error = %v, want canonical safety stop", err) ++ } ++ if _, err := cfg.Modify(func(to *config.Configuration) { ++ to.Folders[0].Path = filepath.Join(t.TempDir(), "replacement") ++ }); !errors.Is(err, config.ErrVaultSyncReceiveSideSafetyStop) { ++ t.Errorf("generic protected path change = %v, want canonical safety stop", err) ++ } ++ logs := issue150SyncthingLogs() ++ protectedFolder := cfg.FolderList()[0] ++ for _, secret := range []string{protectedFolder.Label, protectedFolder.Path} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected app startup log contains user-derived value %q: %s", secret, logs) ++ } ++ } ++} ++ ++func TestIssue150PureSendOnlyAppKeepsStandardStartupServices(t *testing.T) { ++ app, _, spy := issue150RuntimeApp(t, config.FolderTypeSendOnly) ++ if app.receiveSideReadOnlyActive() { ++ t.Fatal("pure send-only configuration entered receive-side protection") ++ } ++ app.mainService = suture.New("issue150-sendonly", svcutil.SpecWithDebugLogger()) ++ if err := app.startup(); err != nil { ++ t.Fatal(err) ++ } ++ for _, call := range []string{"Service", "ListFolders", "GetKV", "PutKV"} { ++ if got := spy.count(call); got == 0 { ++ t.Errorf("pure send-only startup no longer called standard DB surface %s", call) ++ } ++ } ++} ++ ++func issue150RuntimeApp(t *testing.T, folderType config.FolderType) (*App, config.Wrapper, *issue150RuntimeDBSpy) { ++ t.Helper() ++ cert, err := tlsutil.NewCertificateInMemory("issue150", 365) ++ if err != nil { ++ t.Fatal(err) ++ } ++ myID := protocol.NewDeviceID(cert.Certificate[0]) ++ raw := config.New(myID) ++ raw.GUI.Enabled = false ++ raw.Options.RawListenAddresses = nil ++ raw.Options.GlobalAnnEnabled = false ++ raw.Options.LocalAnnEnabled = false ++ raw.Options.RelaysEnabled = false ++ folder := raw.Defaults.Folder.Copy() ++ folder.ID = "issue150-runtime" ++ folder.Label = "Issue 150 Redaction Probe" ++ folder.Path = filepath.Join(t.TempDir(), "issue150-redaction-probe") ++ folder.Type = folderType ++ folder.Paused = true ++ folder.Devices = []config.FolderDeviceConfiguration{{DeviceID: myID}} ++ raw.Folders = []config.FolderConfiguration{folder} ++ cfg := config.Wrap("", raw, myID, events.NoopLogger) ++ go func() { _ = cfg.Serve(t.Context()) }() ++ ++ sdb, err := sqlite.Open(t.TempDir()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ t.Cleanup(func() { sdb.Close() }) ++ spy := &issue150RuntimeDBSpy{DB: sdb, calls: make(map[string]int)} ++ app, err := New(cfg, spy, events.NoopLogger, cert, Options{ ++ ReceiveSideReadOnly: true, ++ ResetDeltaIdxs: true, ++ }) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return app, cfg, spy ++} ++ ++type issue150RuntimeDBSpy struct { ++ db.DB ++ mut sync.Mutex ++ calls map[string]int ++} ++ ++func (s *issue150RuntimeDBSpy) called(name string) { ++ s.mut.Lock() ++ s.calls[name]++ ++ s.mut.Unlock() ++} ++ ++func (s *issue150RuntimeDBSpy) count(name string) int { ++ s.mut.Lock() ++ defer s.mut.Unlock() ++ return s.calls[name] ++} ++ ++func (s *issue150RuntimeDBSpy) Service(interval time.Duration) db.DBService { ++ s.called("Service") ++ return s.DB.Service(interval) ++} ++ ++func (s *issue150RuntimeDBSpy) ListFolders() ([]string, error) { ++ s.called("ListFolders") ++ return s.DB.ListFolders() ++} ++ ++func (s *issue150RuntimeDBSpy) DropFolder(folder string) error { ++ s.called("DropFolder") ++ return s.DB.DropFolder(folder) ++} ++ ++func (s *issue150RuntimeDBSpy) DropAllIndexIDs() error { ++ s.called("DropAllIndexIDs") ++ return s.DB.DropAllIndexIDs() ++} ++ ++func (s *issue150RuntimeDBSpy) GetKV(key string) ([]byte, error) { ++ s.called("GetKV") ++ return s.DB.GetKV(key) ++} ++ ++func (s *issue150RuntimeDBSpy) PutKV(key string, value []byte) error { ++ s.called("PutKV") ++ return s.DB.PutKV(key, value) ++} ++ ++func (s *issue150RuntimeDBSpy) DeleteKV(key string) error { ++ s.called("DeleteKV") ++ return s.DB.DeleteKV(key) ++} ++ ++func issue150SyncthingLogs() string { ++ var messages strings.Builder ++ for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) { ++ messages.WriteString(line.Message) ++ messages.WriteByte('\n') ++ } ++ return messages.String() ++} +diff --git a/lib/syncthing/syncthing.go b/lib/syncthing/syncthing.go +index 1f36642..704fb3b 100644 +--- a/lib/syncthing/syncthing.go ++++ b/lib/syncthing/syncthing.go +@@ -57,22 +57,24 @@ type Options struct { + ProfilerAddr string + ResetDeltaIdxs bool + DBMaintenanceInterval time.Duration ++ ReceiveSideReadOnly bool + } + + type App struct { +- myID protocol.DeviceID +- mainService *suture.Supervisor +- cfg config.Wrapper +- sdb db.DB +- evLogger events.Logger +- cert tls.Certificate +- opts Options +- exitStatus svcutil.ExitStatus +- err error +- stopOnce sync.Once +- mainServiceCancel context.CancelFunc +- stopped chan struct{} +- dbService db.DBService ++ myID protocol.DeviceID ++ mainService *suture.Supervisor ++ cfg config.Wrapper ++ sdb db.DB ++ evLogger events.Logger ++ cert tls.Certificate ++ opts Options ++ exitStatus svcutil.ExitStatus ++ err error ++ stopOnce sync.Once ++ mainServiceCancel context.CancelFunc ++ stopped chan struct{} ++ dbService db.DBService ++ receiveSideReadOnlyStartupWait func() + + // Access to internals for direct users of this package. Note that the interface in Internals is unstable! + Internals *Internals +@@ -111,6 +113,9 @@ func (a *App) Start() error { + a.stopWithErr(svcutil.ExitError, err) + return err + } ++ if a.receiveSideReadOnlyStartupWait != nil { ++ a.receiveSideReadOnlyStartupWait() ++ } + + return nil + } +@@ -127,9 +132,20 @@ func (a *App) LastMaintenanceTime() time.Time { + } + + func (a *App) startup() error { ++ receiveSideReadOnly := a.receiveSideReadOnlyActive() ++ if a.opts.ReceiveSideReadOnly { ++ if err := config.EnableVaultSyncReceiveSideProtection(a.cfg); err != nil { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++ } ++ } ++ + a.mainService.Add(ur.NewFailureHandler(a.cfg, a.evLogger)) + +- a.dbService = a.sdb.Service(a.opts.DBMaintenanceInterval) ++ if receiveSideReadOnly { ++ a.dbService = receiveSideReadOnlyDBService{} ++ } else { ++ a.dbService = a.sdb.Service(a.opts.DBMaintenanceInterval) ++ } + a.mainService.Add(a.dbService) + + if a.opts.AuditWriter != nil { +@@ -182,7 +198,7 @@ func (a *App) startup() error { + }() + } + +- if a.opts.ResetDeltaIdxs { ++ if a.opts.ResetDeltaIdxs && !receiveSideReadOnly { + slog.Info("Reinitializing delta index IDs") + if err := a.sdb.DropAllIndexIDs(); err != nil { + slog.Error("Failed to drop index IDs", slogutil.Error(err)) +@@ -197,62 +213,78 @@ func (a *App) startup() error { + locations.Get(locations.KeyFile), + } + +- // Remove database entries for folders that no longer exist in the config +- cfgFolders := a.cfg.Folders() +- dbFolders, err := a.sdb.ListFolders() +- if err != nil { +- slog.Warn("Failed to list folders", slogutil.Error(err)) +- return err +- } +- for _, folder := range dbFolders { +- if _, ok := cfgFolders[folder]; !ok { +- slog.Info("Cleaning metadata for dropped folder", "folder", folder) +- a.sdb.DropFolder(folder) ++ if !receiveSideReadOnly { ++ // Remove database entries for folders that no longer exist in the config. ++ cfgFolders := a.cfg.Folders() ++ dbFolders, err := a.sdb.ListFolders() ++ if err != nil { ++ slog.Warn("Failed to list folders", slogutil.Error(err)) ++ return err ++ } ++ for _, folder := range dbFolders { ++ if _, ok := cfgFolders[folder]; !ok { ++ slog.Info("Cleaning metadata for dropped folder", "folder", folder) ++ a.sdb.DropFolder(folder) ++ } + } + } + + // Grab the previously running version string from the database. + + miscDB := db.NewMiscDB(a.sdb) +- prevVersion, _, err := miscDB.String("prevVersion") +- if err != nil { +- slog.Error("Database error when getting previous version", slogutil.Error(err)) +- return err +- } ++ if !receiveSideReadOnly { ++ prevVersion, _, err := miscDB.String("prevVersion") ++ if err != nil { ++ slog.Error("Database error when getting previous version", slogutil.Error(err)) ++ return err ++ } + +- // Strip away prerelease/beta stuff and just compare the release +- // numbers. 0.14.44 to 0.14.45-banana is an upgrade, 0.14.45-banana to +- // 0.14.45-pineapple is not. ++ // Strip away prerelease/beta stuff and just compare the release ++ // numbers. 0.14.44 to 0.14.45-banana is an upgrade, 0.14.45-banana to ++ // 0.14.45-pineapple is not. + +- prevParts := strings.Split(prevVersion, "-") +- curParts := strings.Split(build.Version, "-") +- if rel := upgrade.CompareVersions(prevParts[0], curParts[0]); rel != upgrade.Equal { +- if prevVersion != "" { +- slog.Info("Detected upgrade", "from", prevVersion, "to", build.Version) +- } ++ prevParts := strings.Split(prevVersion, "-") ++ curParts := strings.Split(build.Version, "-") ++ if rel := upgrade.CompareVersions(prevParts[0], curParts[0]); rel != upgrade.Equal { ++ if prevVersion != "" { ++ slog.Info("Detected upgrade", "from", prevVersion, "to", build.Version) ++ } + +- if a.cfg.Options().SendFullIndexOnUpgrade { +- // Drop delta indexes in case we've changed random stuff we +- // shouldn't have. We will resend our index on next connect. +- if err := a.sdb.DropAllIndexIDs(); err != nil { +- slog.Warn("Failed to drop index IDs", slogutil.Error(err)) +- return err ++ if a.cfg.Options().SendFullIndexOnUpgrade { ++ // Drop delta indexes in case we've changed random stuff we ++ // shouldn't have. We will resend our index on next connect. ++ if err := a.sdb.DropAllIndexIDs(); err != nil { ++ slog.Warn("Failed to drop index IDs", slogutil.Error(err)) ++ return err ++ } + } + } +- } + +- if build.Version != prevVersion { +- // Remember the new version. +- miscDB.PutString("prevVersion", build.Version) +- } ++ if build.Version != prevVersion { ++ // Remember the new version. ++ miscDB.PutString("prevVersion", build.Version) ++ } + +- if err := globalMigration(a.sdb, a.cfg); err != nil { +- slog.Warn("Failed to perform global migration", slogutil.Error(err)) +- return err ++ if err := globalMigration(a.sdb, a.cfg); err != nil { ++ slog.Warn("Failed to perform global migration", slogutil.Error(err)) ++ return err ++ } + } + + keyGen := protocol.NewKeyGenerator() +- m := model.NewModel(a.cfg, a.myID, a.sdb, protectedFiles, a.evLogger, keyGen) ++ var m model.Model ++ if a.opts.ReceiveSideReadOnly { ++ m = model.NewModelWithOptions(a.cfg, a.myID, a.sdb, protectedFiles, a.evLogger, keyGen, model.ModelOptions{ReceiveSideReadOnly: true}) ++ } else { ++ m = model.NewModel(a.cfg, a.myID, a.sdb, protectedFiles, a.evLogger, keyGen) ++ } ++ if receiveSideReadOnly { ++ startupWaiter, ok := m.(interface{ WaitForStartup() }) ++ if !ok { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++ } ++ a.receiveSideReadOnlyStartupWait = startupWaiter.WaitForStartup ++ } + a.Internals = newInternals(m) + + a.mainService.Add(m) +@@ -283,16 +315,18 @@ func (a *App) startup() error { + a.mainService.Add(discoveryManager) + a.mainService.Add(connectionsService) + +- a.cfg.Modify(func(cfg *config.Configuration) { +- // Candidate builds always run with usage reporting. +- if build.IsCandidate { +- slog.Info("Anonymous usage reporting is always enabled for candidate releases") +- if cfg.Options.URAccepted != ur.Version { +- cfg.Options.URAccepted = ur.Version +- // Unique ID will be set and config saved below if necessary. ++ if !receiveSideReadOnly { ++ a.cfg.Modify(func(cfg *config.Configuration) { ++ // Candidate builds always run with usage reporting. ++ if build.IsCandidate { ++ slog.Info("Anonymous usage reporting is always enabled for candidate releases") ++ if cfg.Options.URAccepted != ur.Version { ++ cfg.Options.URAccepted = ur.Version ++ // Unique ID will be set and config saved below if necessary. ++ } + } +- } +- }) ++ }) ++ } + + usageReportingSvc := ur.New(a.cfg, m, connectionsService, a.opts.NoUpgrade) + a.mainService.Add(usageReportingSvc) +@@ -305,10 +339,18 @@ func (a *App) startup() error { + } + + myDev, _ := a.cfg.Device(a.myID) +- slog.Info("Loaded configuration", "name", myDev.Name) ++ if receiveSideReadOnly { ++ slog.Info("Loaded configuration", a.myID.LogAttr()) ++ } else { ++ slog.Info("Loaded configuration", "name", myDev.Name) ++ } + for _, device := range a.cfg.Devices() { + if device.DeviceID != a.myID { +- slog.Info("Loaded peer device configuration", device.DeviceID.LogAttr(), slog.String("name", device.Name), slogutil.Address(device.Addresses)) ++ if receiveSideReadOnly { ++ slog.Info("Loaded peer device configuration", device.DeviceID.LogAttr()) ++ } else { ++ slog.Info("Loaded peer device configuration", device.DeviceID.LogAttr(), slog.String("name", device.Name), slogutil.Address(device.Addresses)) ++ } + } + } + +@@ -329,6 +371,35 @@ func (a *App) startup() error { + return nil + } + ++func (a *App) receiveSideReadOnlyActive() bool { ++ if !a.opts.ReceiveSideReadOnly { ++ return false ++ } ++ for _, folder := range a.cfg.Folders() { ++ if folder.Type != config.FolderTypeSendOnly { ++ return true ++ } ++ } ++ return false ++} ++ ++type receiveSideReadOnlyDBService struct{} ++ ++func (receiveSideReadOnlyDBService) Serve(ctx context.Context) error { ++ <-ctx.Done() ++ return ctx.Err() ++} ++ ++func (receiveSideReadOnlyDBService) StartMaintenance() <-chan error { ++ done := make(chan error, 1) ++ done <- config.ErrVaultSyncReceiveSideSafetyStop ++ return done ++} ++ ++func (receiveSideReadOnlyDBService) LastMaintenanceTime() time.Time { ++ return time.Time{} ++} ++ + func (a *App) wait(errChan <-chan error) { + err := <-errChan + a.handleMainServiceError(err) +diff --git a/lib/syncthing/utils.go b/lib/syncthing/utils.go +index ad79937..a619984 100644 +--- a/lib/syncthing/utils.go ++++ b/lib/syncthing/utils.go +@@ -86,6 +86,21 @@ func DefaultConfig(path string, myID protocol.DeviceID, evLogger events.Logger, + // upgrades the config if necessary or returns an error, if the version + // isn't compatible. + func LoadConfigAtStartup(path string, cert tls.Certificate, evLogger events.Logger, allowNewerConfig, skipPortProbing bool) (config.Wrapper, error) { ++ return loadConfigAtStartup(path, cert, evLogger, allowNewerConfig, skipPortProbing, nil) ++} ++ ++// ConfigStartupPreflight validates external startup state after the ++// configuration is readable but before an older configuration is archived or ++// rewritten. ++type ConfigStartupPreflight func(config.Wrapper) error ++ ++// LoadConfigAtStartupWithPreflight preserves LoadConfigAtStartup semantics but ++// lets an embedding application stop before configuration-upgrade mutation. ++func LoadConfigAtStartupWithPreflight(path string, cert tls.Certificate, evLogger events.Logger, allowNewerConfig, skipPortProbing bool, preflight ConfigStartupPreflight) (config.Wrapper, error) { ++ return loadConfigAtStartup(path, cert, evLogger, allowNewerConfig, skipPortProbing, preflight) ++} ++ ++func loadConfigAtStartup(path string, cert tls.Certificate, evLogger events.Logger, allowNewerConfig, skipPortProbing bool, preflight ConfigStartupPreflight) (config.Wrapper, error) { + myID := protocol.NewDeviceID(cert.Certificate[0]) + cfg, originalVersion, err := config.Load(path, myID, evLogger) + if fs.IsNotExist(err) { +@@ -104,10 +119,17 @@ func LoadConfigAtStartup(path string, cert tls.Certificate, evLogger events.Logg + return nil, fmt.Errorf("failed to load config: %w", err) + } + +- if originalVersion != config.CurrentVersion { +- if originalVersion > config.CurrentVersion && !allowNewerConfig { +- return nil, fmt.Errorf("config file version (%d) is newer than supported version (%d); if this is expected, use --allow-newer-config to override", originalVersion, config.CurrentVersion) ++ if originalVersion > config.CurrentVersion && !allowNewerConfig { ++ return nil, fmt.Errorf("config file version (%d) is newer than supported version (%d); if this is expected, use --allow-newer-config to override", originalVersion, config.CurrentVersion) ++ } ++ ++ if preflight != nil { ++ if err := preflight(cfg); err != nil { ++ return nil, fmt.Errorf("startup preflight: %w", err) + } ++ } ++ ++ if originalVersion != config.CurrentVersion { + err = archiveAndSaveConfig(cfg, originalVersion) + if err != nil { + return nil, fmt.Errorf("config archive: %w", err) +@@ -144,10 +166,57 @@ func copyFile(src, dst string) error { + return nil + } + ++// ErrReceiveSideReadOnlySafetyStop is the canonical path-free VaultSync ++// receive-side safety stop. ++var ErrReceiveSideReadOnlySafetyStop = config.ErrVaultSyncReceiveSideSafetyStop ++ ++type databaseOptions struct { ++ receiveSideReadOnlyConfig config.Wrapper ++ receiveSideReadOnlyConfigRequested bool ++} ++ ++// DatabaseOption configures database startup behavior. ++type DatabaseOption func(*databaseOptions) ++ ++// WithReceiveSideReadOnlyConfig enables the VaultSync database hard floor for ++// configurations containing at least one non-SendOnly folder. ++func WithReceiveSideReadOnlyConfig(cfg config.Wrapper) DatabaseOption { ++ return func(options *databaseOptions) { ++ options.receiveSideReadOnlyConfig = cfg ++ options.receiveSideReadOnlyConfigRequested = true ++ } ++} ++ + // Opens a database +-func OpenDatabase(path string, deleteRetention time.Duration) (db.DB, error) { +- sql, err := sqlite.Open(path, sqlite.WithDeleteRetention(deleteRetention)) ++func OpenDatabase(path string, deleteRetention time.Duration, options ...DatabaseOption) (db.DB, error) { ++ settings := databaseOptions{} ++ for _, option := range options { ++ option(&settings) ++ } ++ ++ sqliteOptions := []sqlite.Option{sqlite.WithDeleteRetention(deleteRetention)} ++ protected := false ++ if settings.receiveSideReadOnlyConfigRequested { ++ if settings.receiveSideReadOnlyConfig == nil { ++ return nil, ErrReceiveSideReadOnlySafetyStop ++ } ++ var protectedFolderIDs []string ++ for _, folder := range settings.receiveSideReadOnlyConfig.FolderList() { ++ if folder.Type != config.FolderTypeSendOnly { ++ protectedFolderIDs = append(protectedFolderIDs, folder.ID) ++ } ++ } ++ if len(protectedFolderIDs) > 0 { ++ protected = true ++ sqliteOptions = append(sqliteOptions, sqlite.WithReceiveSideReadOnlyFolders(protectedFolderIDs)) ++ } ++ } ++ ++ sql, err := sqlite.Open(path, sqliteOptions...) + if err != nil { ++ if protected { ++ return nil, ErrReceiveSideReadOnlySafetyStop ++ } + return nil, err + } + +diff --git a/lib/versioner/external.go b/lib/versioner/external.go +index 5a9ead4..cc8f60a 100644 +--- a/lib/versioner/external.go ++++ b/lib/versioner/external.go +@@ -24,7 +24,7 @@ import ( + + func init() { + // Register the constructor for this type of versioner with the name "external" +- factories["external"] = newExternal ++ factories["external"] = newExternalWithLogging + } + + type external struct { +@@ -33,6 +33,10 @@ type external struct { + } + + func newExternal(cfg config.FolderConfiguration) Versioner { ++ return newExternalWithLogging(cfg, true) ++} ++ ++func newExternalWithLogging(cfg config.FolderConfiguration, logInstantiation bool) Versioner { + command := cfg.Versioning.Params["command"] + + if build.IsWindows { +@@ -44,7 +48,9 @@ func newExternal(cfg config.FolderConfiguration) Versioner { + filesystem: cfg.Filesystem(), + } + +- l.Debugf("instantiated %#v", s) ++ if logInstantiation { ++ l.Debugf("instantiated %#v", s) ++ } + return s + } + +diff --git a/lib/versioner/issue150_runtime_privacy_test.go b/lib/versioner/issue150_runtime_privacy_test.go +new file mode 100644 +index 0000000..67ea001 +--- /dev/null ++++ b/lib/versioner/issue150_runtime_privacy_test.go +@@ -0,0 +1,218 @@ ++// Copyright (C) 2026 The Syncthing Authors. ++// ++// This Source Code Form is subject to the terms of the Mozilla Public ++// License, v. 2.0. If a copy of the MPL was not distributed with this file, ++// You can obtain one at https://mozilla.org/MPL/2.0/. ++ ++package versioner ++ ++import ( ++ "context" ++ "errors" ++ "log/slog" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/syncthing/syncthing/internal/slogutil" ++ "github.com/syncthing/syncthing/lib/config" ++) ++ ++func TestIssue150ProtectedVersionerIsPrivateAndInspectionOnly(t *testing.T) { ++ for _, tc := range []struct { ++ name string ++ folderType config.FolderType ++ protected bool ++ }{ ++ {name: "send-receive", folderType: config.FolderTypeSendReceive, protected: true}, ++ {name: "receive-only", folderType: config.FolderTypeReceiveOnly, protected: true}, ++ {name: "receive-encrypted", folderType: config.FolderTypeReceiveEncrypted, protected: true}, ++ {name: "receive-only-normal-control", folderType: config.FolderTypeReceiveOnly}, ++ {name: "send-only-control", folderType: config.FolderTypeSendOnly}, ++ } { ++ t.Run(tc.name, func(t *testing.T) { ++ base := t.TempDir() ++ folderPath := filepath.Join(base, "issue150-redaction-probe") ++ versionsPath := filepath.Join(base, ".stversions-issue150-redaction-probe") ++ if err := os.MkdirAll(folderPath, 0o755); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.MkdirAll(versionsPath, 0o755); err != nil { ++ t.Fatal(err) ++ } ++ ++ cfg := config.FolderConfiguration{ ++ ID: "issue150-versioner", ++ Label: "Issue 150 Redaction Probe", ++ Type: tc.folderType, ++ FilesystemType: config.FilesystemTypeBasic, ++ Path: folderPath, ++ Versioning: config.VersioningConfiguration{ ++ Type: "simple", ++ FSPath: versionsPath, ++ FSType: config.FilesystemTypeBasic, ++ Params: map[string]string{"keep": "1"}, ++ }, ++ } ++ ++ archiveName := "archive-note.md" ++ restoreName := "restore-note.md" ++ if err := os.WriteFile(filepath.Join(folderPath, archiveName), []byte("archive-live"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.WriteFile(filepath.Join(folderPath, restoreName), []byte("restore-live"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ versionTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.Local) ++ tag := versionTime.Format(TimeFormat) ++ versionName := TagFilename(restoreName, tag) ++ if err := os.WriteFile(filepath.Join(versionsPath, versionName), []byte("restore-archived"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ olderName := TagFilename("cleanup-note.md", "20180102-030405") ++ newerName := TagFilename("cleanup-note.md", "20190102-030405") ++ for _, name := range []string{olderName, newerName} { ++ if err := os.WriteFile(filepath.Join(versionsPath, name), []byte(name), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ } ++ ++ oldLevel := slogutil.PackageLevels()["versioner"] ++ slogutil.SetPackageLevel("versioner", slog.LevelDebug) ++ t.Cleanup(func() { slogutil.SetPackageLevel("versioner", oldLevel) }) ++ slogutil.GlobalRecorder.Clear() ++ var v Versioner ++ var err error ++ if tc.protected { ++ v, err = NewVaultSyncInspectionOnly(cfg) ++ } else { ++ v, err = New(cfg) ++ } ++ if err != nil { ++ t.Fatal(err) ++ } ++ ++ versions, err := v.GetVersions() ++ if err != nil { ++ t.Fatalf("inspection failed: %v", err) ++ } ++ if got := len(versions[restoreName]); got != 1 { ++ t.Fatalf("inspection found %d restore versions, want 1", got) ++ } ++ ++ archiveErr := v.Archive(archiveName) ++ restoreErr := v.Restore(restoreName, versionTime) ++ cleanErr := v.Clean(context.Background()) ++ if cleanErr != nil { ++ t.Errorf("clean returned an error: %v", cleanErr) ++ } ++ ++ archiveExists := issue150PathExists(filepath.Join(folderPath, archiveName)) ++ restored, err := os.ReadFile(filepath.Join(folderPath, restoreName)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ olderExists := issue150PathExists(filepath.Join(versionsPath, olderName)) ++ ++ if tc.protected { ++ issue150RequireVersionerSafetyError(t, archiveErr) ++ issue150RequireVersionerSafetyError(t, restoreErr) ++ if !archiveExists { ++ t.Error("protected Archive mutated the live vault") ++ } ++ if string(restored) != "restore-live" { ++ t.Errorf("protected Restore changed live content to %q", restored) ++ } ++ if !olderExists { ++ t.Error("protected Clean removed a retained inspection version") ++ } ++ logs := issue150VersionerLogs() ++ for _, secret := range []string{cfg.Label, folderPath, versionsPath, DefaultPath} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected versioner construction logged %q: %s", secret, logs) ++ } ++ } ++ } else { ++ if archiveErr != nil { ++ t.Errorf("send-only Archive returned an error: %v", archiveErr) ++ } ++ if restoreErr != nil { ++ t.Errorf("send-only Restore returned an error: %v", restoreErr) ++ } ++ if archiveExists { ++ t.Error("send-only Archive became inert") ++ } ++ if string(restored) != "restore-archived" { ++ t.Errorf("send-only Restore returned %q", restored) ++ } ++ if olderExists { ++ t.Error("send-only Clean no longer applies retention") ++ } ++ } ++ }) ++ } ++ ++ t.Run("external-command-path", func(t *testing.T) { ++ base := t.TempDir() ++ commandPath := filepath.Join(base, "issue150-redaction-probe-command") ++ cfg := config.FolderConfiguration{ ++ ID: "issue150-external-versioner", ++ Label: "Issue 150 Redaction Probe", ++ Type: config.FolderTypeReceiveOnly, ++ FilesystemType: config.FilesystemTypeBasic, ++ Path: filepath.Join(base, "issue150-redaction-probe"), ++ Versioning: config.VersioningConfiguration{ ++ Type: "external", ++ Params: map[string]string{"command": commandPath + " %FILE_PATH%"}, ++ }, ++ } ++ oldLevel := slogutil.PackageLevels()["versioner"] ++ slogutil.SetPackageLevel("versioner", slog.LevelDebug) ++ t.Cleanup(func() { slogutil.SetPackageLevel("versioner", oldLevel) }) ++ slogutil.GlobalRecorder.Clear() ++ v, err := NewVaultSyncInspectionOnly(cfg) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if _, err := v.GetVersions(); !errors.Is(err, ErrRestorationNotSupported) { ++ t.Errorf("external inspection error = %v, want restoration-not-supported", err) ++ } ++ issue150RequireVersionerSafetyError(t, v.Archive("issue150-redaction-probe.md")) ++ logs := issue150VersionerLogs() ++ for _, secret := range []string{cfg.Label, cfg.Path, commandPath, "issue150-redaction-probe.md"} { ++ if strings.Contains(logs, secret) { ++ t.Errorf("protected external versioner log contains %q: %s", secret, logs) ++ } ++ } ++ }) ++} ++ ++func issue150RequireVersionerSafetyError(t *testing.T, err error) { ++ t.Helper() ++ expected := config.ErrVaultSyncReceiveSideSafetyStop ++ if err == nil { ++ t.Fatalf("mutation returned nil, want %q", expected.Error()) ++ } ++ if !errors.Is(err, expected) { ++ t.Errorf("mutation error = %v, want canonical safety stop", err) ++ } ++ if got := err.Error(); got != expected.Error() { ++ t.Errorf("mutation error = %q, want exact path-free %q", got, expected.Error()) ++ } ++} ++ ++func issue150PathExists(path string) bool { ++ _, err := os.Stat(path) ++ return err == nil ++} ++ ++func issue150VersionerLogs() string { ++ var messages strings.Builder ++ for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) { ++ messages.WriteString(line.Message) ++ messages.WriteByte('\n') ++ } ++ return messages.String() ++} +diff --git a/lib/versioner/simple.go b/lib/versioner/simple.go +index 25ec04a..b75e16f 100644 +--- a/lib/versioner/simple.go ++++ b/lib/versioner/simple.go +@@ -18,7 +18,7 @@ import ( + + func init() { + // Register the constructor for this type of versioner with the name "simple" +- factories["simple"] = newSimple ++ factories["simple"] = newSimpleWithLogging + } + + type simple struct { +@@ -30,6 +30,10 @@ type simple struct { + } + + func newSimple(cfg config.FolderConfiguration) Versioner { ++ return newSimpleWithLogging(cfg, true) ++} ++ ++func newSimpleWithLogging(cfg config.FolderConfiguration, logInstantiation bool) Versioner { + keep, err := strconv.Atoi(cfg.Versioning.Params["keep"]) + cleanoutDays, _ := strconv.Atoi(cfg.Versioning.Params["cleanoutDays"]) + // On error we default to 0, "do not clean out the versioned items" +@@ -42,11 +46,13 @@ func newSimple(cfg config.FolderConfiguration) Versioner { + keep: keep, + cleanoutDays: cleanoutDays, + folderFs: cfg.Filesystem(), +- versionsFs: versionerFsFromFolderCfg(cfg), ++ versionsFs: versionerFsFromFolderCfgWithLogging(cfg, logInstantiation), + copyRangeMethod: cfg.CopyRangeMethod.ToFS(), + } + +- l.Debugf("instantiated %#v", s) ++ if logInstantiation { ++ l.Debugf("instantiated %#v", s) ++ } + return s + } + +diff --git a/lib/versioner/staggered.go b/lib/versioner/staggered.go +index 310d3d1..52f9507 100644 +--- a/lib/versioner/staggered.go ++++ b/lib/versioner/staggered.go +@@ -19,7 +19,7 @@ import ( + + func init() { + // Register the constructor for this type of versioner with the name "staggered" +- factories["staggered"] = newStaggered ++ factories["staggered"] = newStaggeredWithLogging + } + + type interval struct { +@@ -35,13 +35,17 @@ type staggered struct { + } + + func newStaggered(cfg config.FolderConfiguration) Versioner { ++ return newStaggeredWithLogging(cfg, true) ++} ++ ++func newStaggeredWithLogging(cfg config.FolderConfiguration, logInstantiation bool) Versioner { + params := cfg.Versioning.Params + maxAge, err := strconv.ParseInt(params["maxAge"], 10, 0) + if err != nil { + maxAge = 31536000 // Default: ~1 year + } + +- versionsFs := versionerFsFromFolderCfg(cfg) ++ versionsFs := versionerFsFromFolderCfgWithLogging(cfg, logInstantiation) + + s := &staggered{ + folderFs: cfg.Filesystem(), +@@ -55,7 +59,9 @@ func newStaggered(cfg config.FolderConfiguration) Versioner { + copyRangeMethod: cfg.CopyRangeMethod.ToFS(), + } + +- l.Debugf("instantiated %#v", s) ++ if logInstantiation { ++ l.Debugf("instantiated %#v", s) ++ } + return s + } + +diff --git a/lib/versioner/trashcan.go b/lib/versioner/trashcan.go +index 2e22890..4128da2 100644 +--- a/lib/versioner/trashcan.go ++++ b/lib/versioner/trashcan.go +@@ -18,7 +18,7 @@ import ( + + func init() { + // Register the constructor for this type of versioner +- factories["trashcan"] = newTrashcan ++ factories["trashcan"] = newTrashcanWithLogging + } + + type trashcan struct { +@@ -29,17 +29,23 @@ type trashcan struct { + } + + func newTrashcan(cfg config.FolderConfiguration) Versioner { ++ return newTrashcanWithLogging(cfg, true) ++} ++ ++func newTrashcanWithLogging(cfg config.FolderConfiguration, logInstantiation bool) Versioner { + cleanoutDays, _ := strconv.Atoi(cfg.Versioning.Params["cleanoutDays"]) + // On error we default to 0, "do not clean out the trash can" + + s := &trashcan{ + folderFs: cfg.Filesystem(), +- versionsFs: versionerFsFromFolderCfg(cfg), ++ versionsFs: versionerFsFromFolderCfgWithLogging(cfg, logInstantiation), + cleanoutDays: cleanoutDays, + copyRangeMethod: cfg.CopyRangeMethod.ToFS(), + } + +- l.Debugf("instantiated %#v", s) ++ if logInstantiation { ++ l.Debugf("instantiated %#v", s) ++ } + return s + } + +diff --git a/lib/versioner/util.go b/lib/versioner/util.go +index ebb358f..2abf69b 100644 +--- a/lib/versioner/util.go ++++ b/lib/versioner/util.go +@@ -305,6 +305,10 @@ func restoreFile(method fs.CopyRangeMethod, src, dst fs.Filesystem, filePath str + } + + func versionerFsFromFolderCfg(cfg config.FolderConfiguration) (versionsFs fs.Filesystem) { ++ return versionerFsFromFolderCfgWithLogging(cfg, true) ++} ++ ++func versionerFsFromFolderCfgWithLogging(cfg config.FolderConfiguration, logInstantiation bool) (versionsFs fs.Filesystem) { + folderFs := cfg.Filesystem() + if cfg.Versioning.FSPath == "" { + versionsFs = fs.NewFilesystem(folderFs.Type(), filepath.Join(folderFs.URI(), DefaultPath)) +@@ -325,7 +329,9 @@ func versionerFsFromFolderCfg(cfg config.FolderConfiguration) (versionsFs fs.Fil + } else { + versionsFs = fs.NewFilesystem(cfg.Versioning.FSType.ToFS(), cfg.Versioning.FSPath) + } +- l.Debugf("%s (%s) folder using %s (%s) versioner dir", folderFs.URI(), folderFs.Type(), versionsFs.URI(), versionsFs.Type()) ++ if logInstantiation { ++ l.Debugf("%s (%s) folder using %s (%s) versioner dir", folderFs.URI(), folderFs.Type(), versionsFs.URI(), versionsFs.Type()) ++ } + return + } + +diff --git a/lib/versioner/versioner.go b/lib/versioner/versioner.go +index 0f44bbf..d3a4d5a 100644 +--- a/lib/versioner/versioner.go ++++ b/lib/versioner/versioner.go +@@ -12,6 +12,7 @@ import ( + "context" + "errors" + "fmt" ++ "sync" + "time" + + "github.com/syncthing/syncthing/lib/config" +@@ -30,7 +31,7 @@ type FileVersion struct { + Size int64 `json:"size"` + } + +-type factory func(cfg config.FolderConfiguration) Versioner ++type factory func(cfg config.FolderConfiguration, logInstantiation bool) Versioner + + var factories = make(map[string]factory) + +@@ -42,17 +43,67 @@ const ( + ) + + func New(cfg config.FolderConfiguration) (Versioner, error) { ++ return newWithLogging(cfg, true) ++} ++ ++func newWithLogging(cfg config.FolderConfiguration, logInstantiation bool) (Versioner, error) { + fac, ok := factories[cfg.Versioning.Type] + if !ok { + return nil, fmt.Errorf("requested versioning type %q does not exist", cfg.Versioning.Type) + } + + return &versionerWithErrorContext{ +- Versioner: fac(cfg), ++ Versioner: fac(cfg, logInstantiation), + vtype: cfg.Versioning.Type, + }, nil + } + ++// NewVaultSyncInspectionOnly returns a versioner boundary that preserves real ++// version inspection while refusing every operation that can change live or ++// archived content. Construction is lazy so protected folder startup performs ++// no filesystem work merely because versioning is configured. ++func NewVaultSyncInspectionOnly(cfg config.FolderConfiguration) (Versioner, error) { ++ if _, ok := factories[cfg.Versioning.Type]; !ok { ++ return nil, fmt.Errorf("requested versioning type %q does not exist", cfg.Versioning.Type) ++ } ++ return &vaultSyncInspectionOnlyVersioner{cfg: cfg}, nil ++} ++ ++type vaultSyncInspectionOnlyVersioner struct { ++ cfg config.FolderConfiguration ++ ++ once sync.Once ++ ver Versioner ++ err error ++} ++ ++func (v *vaultSyncInspectionOnlyVersioner) inspectionVersioner() (Versioner, error) { ++ v.once.Do(func() { ++ v.ver, v.err = newWithLogging(v.cfg, false) ++ }) ++ return v.ver, v.err ++} ++ ++func (*vaultSyncInspectionOnlyVersioner) Archive(string) error { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++} ++ ++func (v *vaultSyncInspectionOnlyVersioner) GetVersions() (map[string][]FileVersion, error) { ++ ver, err := v.inspectionVersioner() ++ if err != nil { ++ return nil, err ++ } ++ return ver.GetVersions() ++} ++ ++func (*vaultSyncInspectionOnlyVersioner) Restore(string, time.Time) error { ++ return config.ErrVaultSyncReceiveSideSafetyStop ++} ++ ++func (*vaultSyncInspectionOnlyVersioner) Clean(context.Context) error { ++ return nil ++} ++ + type versionerWithErrorContext struct { + Versioner + + diff --git a/ios/VaultSync/App/AppDelegate.swift b/ios/VaultSync/App/AppDelegate.swift index e3b1a99..6082202 100644 --- a/ios/VaultSync/App/AppDelegate.swift +++ b/ios/VaultSync/App/AppDelegate.swift @@ -120,14 +120,24 @@ class AppDelegate: NSObject, UIApplicationDelegate { ) logger.info("Silent push finished with result=\(result.rawValue, privacy: .public)") - switch result { - case .synced: - completionHandler(.newData) - case .alreadyIdle, .noFoldersConfigured, .settledWithFolderError: - completionHandler(.noData) - case .noBookmarkAccess, .bridgeStartFailed, .notIdleBeforeDeadline, .failed: - completionHandler(.failed) - } + completionHandler(Self.backgroundFetchResult(for: result)) + } + } + + /// A terminal folder error is never a successful silent-push outcome. + /// Reporting `.failed` keeps iOS and Relay evidence from treating a + /// fail-closed conflict-retention stop as an uneventful no-data delivery. + static func backgroundFetchResult( + for result: BackgroundSyncService.SyncResult + ) -> UIBackgroundFetchResult { + switch result { + case .synced: + return .newData + case .alreadyIdle, .noFoldersConfigured: + return .noData + case .noBookmarkAccess, .bridgeStartFailed, .notIdleBeforeDeadline, + .failed, .settledWithFolderError: + return .failed } } diff --git a/ios/VaultSync/App/UIAuditFixture.swift b/ios/VaultSync/App/UIAuditFixture.swift index 699859f..efa220a 100644 --- a/ios/VaultSync/App/UIAuditFixture.swift +++ b/ios/VaultSync/App/UIAuditFixture.swift @@ -10,11 +10,9 @@ import Foundation /// bridge polling (same reasoning as TestHost). Compiled out of release /// builds, so it can never affect shipping behaviour. enum UIAuditFixture { - static let mergeConsent = "merge-consent" static let removalConsent = "removal-consent" static let markerError = "marker-error" static let deviceRemovalConsent = "device-removal-consent" - static let conflictResolveConsent = "conflict-resolve-consent" /// The fixture named by `-uiaudit-fixture `, read via the argument /// domain UserDefaults overlay; nil in any normal run. diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index 98be5f0..5993df5 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -10,10 +10,6 @@ struct VaultSyncApp: App { @State private var syncthingManager: SyncthingManager @State private var vaultManager: VaultManager @State private var subscriptionManager = SubscriptionManager() - // ONE coordinator for both mount points (#92, decision 015): a failure or - // parked merge recorded during onboarding must survive into the home - // screen and keep blocking auto-retries there. - @State private var shareAccept: ShareAcceptCoordinator @State private var lastBackgroundedAt: Date? @Environment(\.scenePhase) private var scenePhase @@ -24,9 +20,6 @@ struct VaultSyncApp: App { let vault = VaultManager() _syncthingManager = State(initialValue: syncthing) _vaultManager = State(initialValue: vault) - _shareAccept = State(initialValue: ShareAcceptCoordinator( - environment: .live(syncthingManager: syncthing, vaultManager: vault) - )) // Conflict banners default ON. Registered defaults are per-process and // not persisted, so the background handler still relies on its own // `?? true` fallback — this only keeps foreground `bool(forKey:)` reads @@ -55,16 +48,14 @@ struct VaultSyncApp: App { ContentView( syncthingManager: syncthingManager, vaultManager: vaultManager, - subscriptionManager: subscriptionManager, - shareAccept: shareAccept + subscriptionManager: subscriptionManager ) } else { OnboardingView( hasCompletedOnboarding: $hasCompletedOnboarding, syncthingManager: syncthingManager, vaultManager: vaultManager, - subscriptionManager: subscriptionManager, - shareAccept: shareAccept + subscriptionManager: subscriptionManager ) } } diff --git a/ios/VaultSync/Models/SyncUserError.swift b/ios/VaultSync/Models/SyncUserError.swift index 0d05d26..085308e 100644 --- a/ios/VaultSync/Models/SyncUserError.swift +++ b/ios/VaultSync/Models/SyncUserError.swift @@ -11,6 +11,7 @@ enum SyncUserErrorCategory: String, Sendable { case relayProvision = "relay_provision" case fileAccess = "file_access" case folderMarkerMissing = "folder_marker_missing" + case conflictRetentionSafetyStop = "conflict_retention_safety_stop" case unknown } @@ -36,6 +37,26 @@ struct SyncUserError: Identifiable, Equatable, Sendable { static func from(rawMessage: String, fallbackTitle: String = L10n.tr("Sync Error")) -> SyncUserError { let normalized = rawMessage.lowercased() + if normalized.contains("vaultsync-conflict-recovery-unavailable") { + return SyncUserError( + category: .conflictRetentionSafetyStop, + title: L10n.tr("Conflict Recovery Unavailable"), + message: L10n.tr("Conflict recovery actions are not available in this version."), + remediation: L10n.tr("Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version."), + technicalDetails: nil + ) + } + + if normalized.contains(ConflictSafetyPolicy.stoppedReason) + || normalized.contains(ConflictSafetyPolicy.engineStopMarker) { + return conflictSafetyError(for: .stopped) + } + if normalized.contains(ConflictSafetyPolicy.statusUnavailableActionCode) + || normalized.contains(ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason) + || normalized.contains(ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason) { + return conflictSafetyError(for: .unknown) + } + if normalized.contains("syncthing not running") || normalized.contains("not running") { return SyncUserError( category: .syncthingNotRunning, @@ -58,7 +79,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .config, title: L10n.tr("Conflict Resolution Failed"), message: L10n.tr("Keep Both did not change any files because the new copy name is already in use."), - remediation: L10n.tr("Rename the existing copy in Files, then try Keep Both again."), + remediation: L10n.tr("Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available."), technicalDetails: rawMessage ) } @@ -68,7 +89,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .fileAccess, title: L10n.tr("Conflict Resolution Failed"), message: L10n.tr("Keep Both did not change any files because this storage location does not support safe renaming."), - remediation: L10n.tr("Resolve this conflict manually in Files without replacing either file."), + remediation: L10n.tr("Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available."), technicalDetails: rawMessage ) } @@ -188,6 +209,15 @@ struct SyncUserError: Identifiable, Equatable, Sendable { path: String? ) -> SyncUserError { let normalizedReason = (reason ?? "").lowercased() + + if normalizedReason == ConflictSafetyPolicy.stoppedReason { + return conflictSafetyError(for: .stopped) + } + if normalizedReason == ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason + || normalizedReason == ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason { + return conflictSafetyError(for: .unknown) + } + let detail = message ?? L10n.tr("Folder is currently in an error state.") let pathHint = path.map { L10n.fmt(" (%@)", $0) } ?? "" @@ -234,6 +264,27 @@ struct SyncUserError: Identifiable, Equatable, Sendable { } } + static func conflictSafetyError(for state: ConflictSafetyPolicy.State) -> SyncUserError { + switch state { + case .stopped: + return SyncUserError( + category: .conflictRetentionSafetyStop, + title: L10n.tr("Conflict Safety Review Required"), + message: L10n.tr("VaultSync keeps receive-capable vaults read-only in this version."), + remediation: L10n.tr("You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable."), + technicalDetails: nil + ) + case .unknown, .clear: + return SyncUserError( + category: .conflictRetentionSafetyStop, + title: L10n.tr("Conflict Safety Status Unavailable"), + message: L10n.tr("VaultSync keeps receive-capable vaults read-only in this version."), + remediation: L10n.tr("You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable."), + technicalDetails: nil + ) + } + } + /// Syncthing's marker-missing error means the folder was moved, renamed, /// replaced, or deleted outside the app while still configured to sync. /// Doctrine-002 mapping: explain, stay stopped, let the user act — a @@ -249,7 +300,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes.", pathHint ), - remediation: L10n.tr("If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own."), + remediation: L10n.tr("If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own."), technicalDetails: detail ) } @@ -287,7 +338,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { SyncUserError( category: .permission, title: L10n.tr("Push Registration Failed"), - message: L10n.tr("iOS did not provide a push token required for instant sync."), + message: L10n.tr("iOS did not provide a push token required for Cloud Relay wake-ups."), remediation: L10n.tr("Notification permission is not required for this. Check that the iPhone is online and signed in to an Apple Account, then reopen the app so push registration can retry."), technicalDetails: reason ) @@ -316,6 +367,8 @@ struct SyncUserError: Identifiable, Equatable, Sendable { } case .folderMarkerMissing: anchor = "vault-folder-was-moved-or-deleted" + case .conflictRetentionSafetyStop: + return nil case .config, .validation: if details.contains("pending") || details.contains("share") { anchor = "no-pending-shares-appear" diff --git a/ios/VaultSync/Services/BackgroundSyncService.swift b/ios/VaultSync/Services/BackgroundSyncService.swift index fc8dd08..874dfe3 100644 --- a/ios/VaultSync/Services/BackgroundSyncService.swift +++ b/ios/VaultSync/Services/BackgroundSyncService.swift @@ -524,9 +524,9 @@ enum BackgroundSyncService { var shouldSurfaceIssue: Bool { switch self { - case .synced, .alreadyIdle, .settledWithFolderError: + case .synced, .alreadyIdle: return false - case .noBookmarkAccess, .noFoldersConfigured, .bridgeStartFailed, .notIdleBeforeDeadline, .failed: + case .noBookmarkAccess, .noFoldersConfigured, .bridgeStartFailed, .notIdleBeforeDeadline, .failed, .settledWithFolderError: return true } } @@ -543,8 +543,10 @@ enum BackgroundSyncService { return L10n.tr("Background Sync Timed Out") case .failed: return L10n.tr("Background Sync Failed") - case .synced, .alreadyIdle, .settledWithFolderError: + case .synced, .alreadyIdle: return L10n.tr("Background Sync Completed") + case .settledWithFolderError: + return L10n.tr("Background Sync Stopped for Safety") } } @@ -565,28 +567,39 @@ enum BackgroundSyncService { case .alreadyIdle: return L10n.tr("Background sync ran, but folders were already idle.") case .settledWithFolderError: - return L10n.tr("Background sync settled with at least one folder in an error state.") + return L10n.tr("Background sync stopped because the engine or a folder reported a safety issue.") } } var remediation: String { switch self { case .noBookmarkAccess: - return L10n.tr("Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan.") + return L10n.tr("Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version.") case .noFoldersConfigured: - return L10n.tr("Accept or create a shared vault before relying on background sync.") + return L10n.tr("Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version.") case .bridgeStartFailed: return L10n.tr("Open VaultSync once to restart Syncthing, then retry.") case .notIdleBeforeDeadline: return L10n.tr("Open VaultSync to allow a longer foreground sync session.") case .failed: return L10n.tr("Retry from the app and review relay/background diagnostics in Settings.") - case .synced, .alreadyIdle, .settledWithFolderError: + case .synced, .alreadyIdle: return L10n.tr("No action needed.") + case .settledWithFolderError: + return L10n.tr("Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable.") } } } + /// Pure bridge-start classification shared by initial and forced starts. + /// Only the exact path-free #150 marker changes the result; arbitrary + /// bridge detail remains an ordinary start failure and is never surfaced. + static func syncResultForBridgeStartFailure(_ bridgeError: String?) -> SyncResult { + bridgeError == ConflictSafetyPolicy.engineStopMarker + ? .settledWithFolderError + : .bridgeStartFailed + } + static func lastSyncOutcome(defaults: UserDefaults = .standard) -> SyncOutcome? { if let data = defaults.data(forKey: lastSyncOutcomeStorageKey), let decoded = try? JSONDecoder().decode(SyncOutcome.self, from: data) { @@ -661,11 +674,10 @@ enum BackgroundSyncService { } guard didAcquireSyncSlot else { logger.info("Background sync already in flight — coalescing (reason=\(reason))") - trace("Concurrent background sync suppressed (reason=\(reason)); nudging rescan.") - if SyncBridgeService.isRunning() { - _ = requestFolderRescans() - } - return .alreadyIdle + trace("Concurrent background sync suppressed (reason=\(reason)); evaluating rescan.") + guard SyncBridgeService.isRunning() else { return .failed } + let rescanResult = requestFolderRescans() + return coalescedSyncResult(rescanResult: rescanResult) } defer { syncInFlightLock.withLock { $0 = false } } @@ -689,10 +701,19 @@ enum BackgroundSyncService { trace("Bridge state before sync: running=\(alreadyRunning).") if BackgroundSyncGuards.shouldFastPathRescan(reason: reason, bridgeAlreadyRunning: alreadyRunning) { logger.info("Silent push: triggering folder rescans to wake Syncthing peer dialer") - if let rescanCount = requestFolderRescans() { + let rescanResult = requestFolderRescans() + if case let .rescanned(rescanCount) = rescanResult { trace("Silent push fast path: rescanning \(rescanCount) folder(s).") } else { - trace("Silent push fast path: folder decode failed before rescan.") + let result = syncResultForRescanFailure(rescanResult) ?? .failed + trace("Silent push fast path: rescan stopped by safety preflight.") + return completeSync( + reason: reason, + result: result, + detail: result.issueMessage, + startedAt: syncStartedAt, + initialEventCursor: syncStartEventCursor + ) } traceRelevantBridgeEvents(since: &telemetryEventCursor, label: "after-fast-path-rescan") } @@ -729,12 +750,15 @@ enum BackgroundSyncService { let configDir = syncthingConfigDir() let err = SyncBridgeService.startSyncthing(configDir: configDir) if let err, !err.isEmpty, !SyncBridgeService.isRunning() { + let result = syncResultForBridgeStartFailure(err) logger.error("Background bridge start failed") trace("Bridge start failed.") return completeSync( reason: reason, - result: .bridgeStartFailed, - detail: L10n.tr("The embedded sync engine could not start."), + result: result, + detail: result == .settledWithFolderError + ? result.issueMessage + : L10n.tr("The embedded sync engine could not start."), startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) @@ -852,7 +876,7 @@ enum BackgroundSyncService { trace("Forced restart failed.") let completion = completeSync( reason: reason, - result: .bridgeStartFailed, + result: restart.failureResult ?? .bridgeStartFailed, detail: restart.errorDetail ?? L10n.tr("Forced silent-push restart failed."), startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor @@ -898,10 +922,21 @@ enum BackgroundSyncService { FolderPathReconciler.reconcileLive(obsidianRoot: managedAccess.url?.path) } - if let rescanCount = requestFolderRescans() { + let rescanResult = requestFolderRescans() + if case let .rescanned(rescanCount) = rescanResult { trace("Post-restart local rescan requested for \(rescanCount) folder(s).") } else { - trace("Post-restart local rescan skipped because folder decode failed.") + let result = syncResultForRescanFailure(rescanResult) ?? .failed + trace("Post-restart local rescan stopped by safety preflight.") + let completion = completeSync( + reason: reason, + result: result, + detail: result.issueMessage, + startedAt: syncStartedAt, + initialEventCursor: syncStartEventCursor + ) + cleanupBackgroundManaged() + return completion } traceRelevantBridgeEvents(since: &telemetryEventCursor, label: "after-post-restart-rescan") } @@ -967,8 +1002,14 @@ enum BackgroundSyncService { } } - let idle = allFoldersIdle() - let settledWithError = !idle && allFoldersSettledOrErrored() + // Derive the terminal result from one coherent snapshot. Reading idle + // and error settlement separately could cross a safety-stop transition + // and turn contradictory evidence into a success claim. + let finalSettlements = folderSettlements() + let idle = finalSettlements?.allSatisfy { $0 == .idle } == true + let settledWithError = finalSettlements.map { + !$0.allSatisfy { $0 == .idle } && $0.allSatisfy { $0 != .active } + } ?? false let progressSnapshot = progressTracker?.poll() if let progressSnapshot { progressTrackerTraceIfNeeded(progressSnapshot) @@ -998,7 +1039,7 @@ enum BackgroundSyncService { // but don't report a clean success either (the widget should still // reflect the error, not a green idle state). result = .settledWithFolderError - detail = L10n.tr("Background sync settled with at least one folder in an error state.") + detail = result.issueMessage } else { result = .notIdleBeforeDeadline detail = L10n.fmt("Sync did not reach idle before %ds deadline.", Int(maxDuration)) @@ -1446,8 +1487,8 @@ enum BackgroundSyncService { case .noFoldersConfigured, .notIdleBeforeDeadline: severities.append(.warning) case .noBookmarkAccess, .bridgeStartFailed, .failed, .settledWithFolderError: - // settledWithFolderError surfaces in-app via the folderErrors - // issue (critical), not via backgroundSyncIssueItem — same tier. + // A terminal folder error is critical whether the foreground also + // has fresh enough evidence to surface a dedicated folder issue. severities.append(.critical) } return SyncHeaderModel.deriveWidgetStatus( @@ -1529,20 +1570,33 @@ enum BackgroundSyncService { initialEventCursor: Int, localDataProgressObserved: Bool = false ) -> SyncResult { + // Every success claim gets one final coherent status read after the + // caller's last await and event poll. A safety stop, active folder, or + // unreadable evidence appearing in that window cannot be persisted as + // synced/already-idle (#150). + let finalResult = resultAfterFinalStatusValidation( + proposed: result, + finalSettlements: result.isSuccessful ? folderSettlements() : nil + ) + let verifiedLocalDataProgress = validatedLocalDataProgressObserved( + proposed: localDataProgressObserved, + finalResult: finalResult + ) + let finalDetail = finalResult == result ? detail : finalResult.issueMessage let outcome = SyncOutcome( timestamp: Date(), triggerReason: reason, - result: result, - detail: detail, - localDataProgressObserved: localDataProgressObserved + result: finalResult, + detail: finalDetail, + localDataProgressObserved: verifiedLocalDataProgress ) persistSyncOutcome(outcome) - if reason == "silent-push", localDataProgressObserved { + if reason == "silent-push", verifiedLocalDataProgress { RelaySyncProofStore.markLocalDataProgressObserved(at: outcome.timestamp) } WidgetSnapshotStore.write( snapshot: backgroundCompletionSnapshot( - result: result, + result: finalResult, issueFloor: WidgetSnapshotStore.readIssueFloor(), completedAt: outcome.timestamp, startedAt: startedAt, @@ -1552,9 +1606,19 @@ enum BackgroundSyncService { previous: WidgetSnapshotStore.read() ) ) - trace("Completed with result=\(result.rawValue).") - logger.info("Background sync completed (reason=\(reason), result=\(result.rawValue))") - return result + trace("Finished with result=\(finalResult.rawValue).") + logger.info("Background sync finished (reason=\(reason), result=\(finalResult.rawValue))") + return finalResult + } + + /// A progress event is evidence only for a finally successful run. The + /// final safety validation can downgrade a proposed success after that + /// event, in which case neither the outcome nor relay proof may retain it. + static func validatedLocalDataProgressObserved( + proposed: Bool, + finalResult: SyncResult + ) -> Bool { + proposed && finalResult.isSuccessful } private static func persistSyncOutcome(_ outcome: SyncOutcome) { @@ -1572,12 +1636,27 @@ enum BackgroundSyncService { case active // scanning, syncing, or outstanding work to pull } + struct FolderStatusCollection: Equatable, Sendable { + let orderedFolderIDs: [String] + let settlements: [String: FolderSettlement] + let allStatusesReadable: Bool + } + static func folderSettlement( state: String, needFiles: Int, needBytes: Int64, - inProgressBytes: Int64 + inProgressBytes: Int64, + errorReason: String? = nil, + hasRawErrorDetail: Bool = false ) -> FolderSettlement { + let safetyState = ConflictSafetyPolicy.classify( + statusReadable: true, + state: state, + errorReason: errorReason, + hasRawErrorDetail: hasRawErrorDetail + ) + if safetyState != .clear { return .errored } if state == "error" { return .errored } // A folder is truly idle only when Syncthing is neither scanning nor // syncing AND has no outstanding work. The state field alone is not @@ -1590,58 +1669,96 @@ enum BackgroundSyncService { return .active } - /// Per-folder settlement snapshot, or nil when the folder list is empty or a - /// status fails to decode (can't confirm state — caller treats as not-idle). - private static func folderSettlements() -> [FolderSettlement]? { - let json = SyncBridgeService.getFoldersJSON() - guard let data = json.data(using: .utf8), + /// Decode every readable status without side effects. Missing or malformed + /// evidence stays incomplete and callers must treat it as non-success. + static func collectFolderStatusSnapshot( + foldersJSON: String, + statusJSON: (_ folderID: String) -> String + ) -> FolderStatusCollection? { + guard let data = foldersJSON.data(using: .utf8), let folders = try? JSONDecoder().decode([FolderStub].self, from: data), - !folders.isEmpty else { + Set(folders.map(\.id)).count == folders.count, + folders.allSatisfy({ !$0.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil } - var settlements: [FolderSettlement] = [] + var settlements: [String: FolderSettlement] = [:] settlements.reserveCapacity(folders.count) + var allStatusesReadable = true for folder in folders { - let statusJSON = SyncBridgeService.getFolderStatusJSON(folderID: folder.id) - guard let statusData = statusJSON.data(using: .utf8), + let encodedStatus = statusJSON(folder.id) + guard let statusData = encodedStatus.data(using: .utf8), let status = try? JSONDecoder().decode(StatusStub.self, from: statusData) else { - return nil + allStatusesReadable = false + continue } - settlements.append(folderSettlement( + let statusSettlement = folderSettlement( state: status.state, needFiles: status.needFiles, needBytes: status.needBytes, - inProgressBytes: status.inProgressBytes - )) + inProgressBytes: status.inProgressBytes, + errorReason: status.errorReason, + hasRawErrorDetail: status.errorMessage?.isEmpty == false + || status.errorPath?.isEmpty == false + ) + settlements[folder.id] = ConflictSafetyPolicy.runtimeState( + forFolderType: folder.type + ) == .clear ? statusSettlement : .errored + } + return FolderStatusCollection( + orderedFolderIDs: folders.map(\.id), + settlements: settlements, + allStatusesReadable: allStatusesReadable + ) + } + + private static func liveFolderStatusCollection() -> FolderStatusCollection? { + return collectFolderStatusSnapshot( + foldersJSON: SyncBridgeService.getFoldersJSON(), + statusJSON: { SyncBridgeService.getFolderStatusJSON(folderID: $0) } + ) + } + + /// Per-folder settlement snapshot, or nil when the folder list is empty or a + /// status fails to decode (can't confirm state — caller treats as not-idle). + private static func folderSettlements() -> [FolderSettlement]? { + guard let collection = liveFolderStatusCollection() else { return nil } + guard collection.allStatusesReadable, + !collection.orderedFolderIDs.isEmpty, + collection.orderedFolderIDs.allSatisfy({ collection.settlements[$0] != nil }) else { + return nil } - return settlements + return collection.orderedFolderIDs.compactMap { collection.settlements[$0] } } - private static func continuedProcessingFolderSnapshot() -> ContinuedProcessingRun.FolderSnapshot { - let json = SyncBridgeService.getFoldersJSON() - guard let data = json.data(using: .utf8), - let folders = try? JSONDecoder().decode([FolderStub].self, from: data) else { + static func continuedProcessingFolderSnapshot( + foldersJSON: String, + statusJSON: (_ folderID: String) -> String + ) -> ContinuedProcessingRun.FolderSnapshot { + guard let collection = collectFolderStatusSnapshot( + foldersJSON: foldersJSON, + statusJSON: statusJSON + ), collection.allStatusesReadable else { return .unreadable } + return .readable( + folderIDs: Set(collection.orderedFolderIDs), + settlements: collection.settlements + ) + } - let expectedFolderIDs = Set(folders.map(\.id)) - var settlements: [String: FolderSettlement] = [:] - settlements.reserveCapacity(expectedFolderIDs.count) - for folder in folders { - let statusJSON = SyncBridgeService.getFolderStatusJSON(folderID: folder.id) - guard let statusData = statusJSON.data(using: .utf8), - let status = try? JSONDecoder().decode(StatusStub.self, from: statusData) else { - continue - } - settlements[folder.id] = folderSettlement( - state: status.state, - needFiles: status.needFiles, - needBytes: status.needBytes, - inProgressBytes: status.inProgressBytes + private static func continuedProcessingFolderSnapshot() -> ContinuedProcessingRun.FolderSnapshot { + let collection = collectFolderStatusSnapshot( + foldersJSON: SyncBridgeService.getFoldersJSON(), + statusJSON: { SyncBridgeService.getFolderStatusJSON(folderID: $0) } + ) + if let collection, collection.allStatusesReadable { + return .readable( + folderIDs: Set(collection.orderedFolderIDs), + settlements: collection.settlements ) } - return .readable(folderIDs: expectedFolderIDs, settlements: settlements) + return .unreadable } private static func allFoldersIdle() -> Bool { @@ -1691,6 +1808,7 @@ enum BackgroundSyncService { let events = decodeBridgeEvents(from: SyncBridgeService.getEventsSince(lastID: lastEventID)) return events.reduce(into: 0) { count, event in guard event.type == "ItemFinished" else { return } + guard ConflictSafetyPolicy.state(forEventReason: event.data?["reason"]) == nil else { return } let error = event.data?["error"] ?? "" if error.isEmpty { count += 1 @@ -1739,14 +1857,14 @@ enum BackgroundSyncService { let content = UNMutableNotificationContent() content.title = L10n.tr("Sync Conflicts") content.body = count == 1 - ? L10n.tr("1 file has a sync conflict. Open VaultSync to resolve it.") - : L10n.fmt("%d files have sync conflicts. Open VaultSync to resolve them.", count) + ? L10n.tr("1 file has a sync conflict. Open VaultSync to see which copies are still available.") + : L10n.fmt("%d files have sync conflicts. Open VaultSync to see which copies are still available.", count) if action == .alert { content.sound = .default content.interruptionLevel = .active } else { - // Count dropped (some resolved) — refresh the number without a - // sound or screen-wake. + // Count dropped — refresh the number without a sound or + // screen-wake. Do not infer how the files changed. content.interruptionLevel = .passive } @@ -1839,16 +1957,117 @@ enum BackgroundSyncService { return docs.appendingPathComponent("syncthing", isDirectory: true).path } - private static func requestFolderRescans() -> Int? { - let json = SyncBridgeService.getFoldersJSON() - guard let data = json.data(using: .utf8), - let folders = try? JSONDecoder().decode([FolderStub].self, from: data) else { + enum FolderRescanResult: Equatable, Sendable { + case rescanned(Int) + case noFolders + case blocked + case unreadable + case failed + } + + /// Preflights every mutable SendOnly folder before the first rescan. A + /// receive-capable sibling is left untouched, while an exact #150 stop on + /// any target can never leave an earlier folder partially rescanned. + static func requestFolderRescans( + foldersJSON: String, + statusJSON: (_ folderID: String) -> String, + rescan: (_ folderID: String) -> String? + ) -> FolderRescanResult { + guard let data = foldersJSON.data(using: .utf8), + let folders = try? JSONDecoder().decode([FolderStub].self, from: data), + Set(folders.map(\.id)).count == folders.count, + folders.allSatisfy({ !$0.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + return .unreadable + } + guard !folders.isEmpty else { return .noFolders } + + // Folder mode is the permanent 2.0.2 boundary. Receive-capable and + // unknown folders are not automatic-rescan targets; only the selected + // SendOnly subset is read or mutated. + let targets = folders.filter { + ConflictSafetyPolicy.runtimeState(forFolderType: $0.type) == .clear + } + guard !targets.isEmpty else { + return .blocked + } + + func safetyState(folderID: String) -> ConflictSafetyPolicy.State { + let encodedStatus = statusJSON(folderID) + guard let statusData = encodedStatus.data(using: .utf8), + let status = try? JSONDecoder().decode(StatusStub.self, from: statusData) else { + // Folder type already proves this is SendOnly. Preserve its + // established repair rescan and let the bridge enforce the + // mutation hard floor again at the ABI. + return .clear + } + return ConflictSafetyPolicy.state(forEventReason: status.errorReason) ?? .clear + } + + // Complete the read-only pass before the first rescan so a later + // blocked folder cannot leave an earlier folder partially rescanned. + for folder in targets { + let state = safetyState(folderID: folder.id) + guard state == .clear else { return .blocked } + } + + var count = 0 + for folder in targets { + // Re-read synchronously at the individual mutation gate; the + // all-folder preflight above is not treated as a durable lease. + let state = safetyState(folderID: folder.id) + guard state == .clear else { return .blocked } + if let error = rescan(folder.id), !error.isEmpty { + return .failed + } + count += 1 + } + return .rescanned(count) + } + + static func syncResultForRescanFailure(_ result: FolderRescanResult) -> SyncResult? { + switch result { + case .rescanned: return nil + case .noFolders: + return .noFoldersConfigured + case .blocked: + return .settledWithFolderError + case .unreadable, .failed: + return .failed } - for folder in folders { - _ = SyncBridgeService.rescanFolder(folderID: folder.id) + } + + static func resultAfterFinalStatusValidation( + proposed: SyncResult, + finalSettlements: [FolderSettlement]? + ) -> SyncResult { + guard proposed.isSuccessful else { return proposed } + guard let finalSettlements, !finalSettlements.isEmpty else { return .failed } + if finalSettlements.contains(.errored) { + return .settledWithFolderError } - return folders.count + guard finalSettlements.allSatisfy({ $0 == .idle }) else { return .failed } + return proposed + } + + static func coalescedSyncResult( + rescanResult: FolderRescanResult + ) -> SyncResult { + if let failure = syncResultForRescanFailure(rescanResult) { + return failure + } + // Another run owns the lifecycle and a successful rescan request is + // only queued work. The loser has no settlement evidence of its own, + // so it must never emit a success-shaped result (#150). + return .failed + } + + private static func requestFolderRescans() -> FolderRescanResult { + requestFolderRescans( + foldersJSON: SyncBridgeService.getFoldersJSON(), + statusJSON: { SyncBridgeService.getFolderStatusJSON(folderID: $0) }, + rescan: { SyncBridgeService.rescanFolder(folderID: $0) } + ) } private static func waitForSilentPushWakeEvidence(maxWait: TimeInterval) async -> Bool { @@ -1884,25 +2103,32 @@ enum BackgroundSyncService { } private static func forceRestartForSilentPush() - async -> (success: Bool, errorDetail: String?) { + async -> (success: Bool, failureResult: SyncResult?, errorDetail: String?) { if SyncBridgeService.isRunning() { trace("Forced restart stopping running bridge first.") SyncBridgeService.stopSyncthing() try? await Task.sleep(for: .milliseconds(350)) if Task.isCancelled { - return (false, SyncResult.failed.issueMessage) + return (false, .failed, SyncResult.failed.issueMessage) } } let configDir = syncthingConfigDir() let err = SyncBridgeService.startSyncthing(configDir: configDir) if let err, !err.isEmpty, !SyncBridgeService.isRunning() { + let result = syncResultForBridgeStartFailure(err) trace("Forced restart start failed.") - return (false, L10n.tr("The embedded sync engine could not restart.")) + return ( + false, + result, + result == .settledWithFolderError + ? result.issueMessage + : L10n.tr("The embedded sync engine could not restart.") + ) } trace("Forced restart started bridge successfully.") - return (true, nil) + return (true, nil, nil) } private static func traceFolderStatuses(label: String) { @@ -1928,7 +2154,10 @@ enum BackgroundSyncService { state: status.state, needFiles: status.needFiles, needBytes: status.needBytes, - inProgressBytes: status.inProgressBytes + inProgressBytes: status.inProgressBytes, + errorReason: status.errorReason, + hasRawErrorDetail: status.errorMessage?.isEmpty == false + || status.errorPath?.isEmpty == false ) { case .idle: idleCount += 1 case .active: activeCount += 1 @@ -1998,6 +2227,7 @@ enum BackgroundSyncService { private struct FolderStub: Decodable { let id: String + let type: String? } private struct DeviceStub: Decodable { @@ -2022,6 +2252,9 @@ enum BackgroundSyncService { let needFiles: Int let needBytes: Int64 let inProgressBytes: Int64 + let errorReason: String? + let errorMessage: String? + let errorPath: String? } private struct ConflictStub: Decodable { @@ -2087,7 +2320,8 @@ enum BackgroundSyncService { } func indicatesLocalDataProgress(since startedAt: Date) -> Bool { - guard type == "ItemFinished", + guard ConflictSafetyPolicy.state(forEventReason: data?["reason"]) == nil, + type == "ItemFinished", data?["type"] == "file", data?["action"] == "update" || data?["action"] == "delete", (data?["error"] ?? "").isEmpty, diff --git a/ios/VaultSync/Services/ConflictSafetyPolicy.swift b/ios/VaultSync/Services/ConflictSafetyPolicy.swift new file mode 100644 index 0000000..503987f --- /dev/null +++ b/ios/VaultSync/Services/ConflictSafetyPolicy.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Pure interpretation of the bridge's conflict-safety evidence (#150). +/// +/// Engine evidence and the configured folder type are the authorities. Swift +/// deliberately persists no shadow marker: before a fresh status is readable, +/// actions remain blocked as `.unknown`, while the compile-time receive policy +/// reproduces its stop after every restart. This type performs no I/O, logging, +/// configuration, or mutation. +enum ConflictSafetyPolicy { + enum State: Equatable, Sendable { + case clear + case stopped + case unknown + } + + static let stoppedReason = "conflict_retention_safety_stop" + static let engineStopMarker = "vaultsync-conflict-retention-safety-stop" + static let folderErrorEvidenceUnavailableReason = "folder_error_evidence_unavailable" + static let folderCompletionEvidenceUnavailableReason = "folder_completion_evidence_unavailable" + static let statusUnavailableActionCode = "conflict_safety_status_unavailable" + + /// 2.0.2 deliberately keeps every receive-capable folder read-only. This + /// is a compile-time runtime policy, not a preference or persisted latch: + /// a restart cannot silently re-enable receive-side mutation. + static let receiveSideReadOnlyRuntimeEnabled = true + + private static let unavailableReasons: Set = [ + folderErrorEvidenceUnavailableReason, + folderCompletionEvidenceUnavailableReason, + ] + + // `lib/model/folderstate.go` in the pinned Syncthing fork is the wire + // authority. A future or malformed value cannot authorize mutation until + // this allowlist is deliberately reviewed alongside that upgrade. + private static let knownNonErrorStates: Set = [ + "idle", + "scanning", + "scan-waiting", + "sync-waiting", + "sync-preparing", + "syncing", + "cleaning", + "clean-waiting", + ] + + /// Classifies only fixed bridge fields. Raw messages and paths never become + /// part of the decision or an emitted error. + static func classify( + statusReadable: Bool, + state: String?, + errorReason: String?, + hasRawErrorDetail: Bool = false + ) -> State { + guard statusReadable else { return .unknown } + + let normalizedState = state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + let normalizedReason = errorReason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + + if normalizedReason == stoppedReason { + return .stopped + } + if unavailableReasons.contains(normalizedReason) { + return .unknown + } + guard !normalizedState.isEmpty else { + return .unknown + } + + // A reason/detail on a success-shaped state is contradictory evidence. + // Any remaining error state is also insufficient to authorize a + // conflict mutation: arbitrary bridge detail is not a clear signal. + if normalizedState == "error" { + return .unknown + } + if !normalizedReason.isEmpty || hasRawErrorDetail { + return .unknown + } + return knownNonErrorStates.contains(normalizedState) ? .clear : .unknown + } + + static func actionErrorCode(for state: State) -> String? { + switch state { + case .clear: + return nil + case .stopped, .unknown: + // Action ABIs expose one fixed path-free stop. The structured + // status reason still distinguishes a known stop from unavailable + // evidence for read-only UI presentation. + return engineStopMarker + } + } + + static func allowsMutation(for state: State) -> Bool { + state == .clear + } + + /// The configured folder type is an independent authorization input. A + /// clear-shaped status can never override the 2.0.2 receive-side stop, and + /// an unknown future type stays fail-closed until explicitly reviewed. + static func runtimeState(forFolderType type: String?) -> State { + let normalized = type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "sendonly": + return .clear + case "sendreceive", "receiveonly", "receiveencrypted": + return receiveSideReadOnlyRuntimeEnabled ? .stopped : .clear + default: + return .unknown + } + } + + /// Combines independent safety evidence. A durable stop wins over missing + /// evidence; missing evidence wins over an otherwise clear result. + static func aggregate(_ states: [State]) -> State { + guard !states.isEmpty else { return .unknown } + if states.contains(.stopped) { return .stopped } + if states.contains(.unknown) { return .unknown } + return .clear + } + + static func state(forEventReason reason: String?) -> State? { + let normalized = reason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == stoppedReason { + return .stopped + } + if let normalized, unavailableReasons.contains(normalized) { + return .unknown + } + return nil + } +} diff --git a/ios/VaultSync/Services/DiagnosticsPairingController.swift b/ios/VaultSync/Services/DiagnosticsPairingController.swift index df882fe..81cdc10 100644 --- a/ios/VaultSync/Services/DiagnosticsPairingController.swift +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -85,6 +85,7 @@ final class DiagnosticsPairingController { private let uploadRandomBytes: @Sendable (Int) throws -> Data private let uploadFileWriter: @Sendable (String, [String], Data) throws -> Void private let uploadSleep: @Sendable (UInt64) async throws -> Void + private let receiveSideReadOnlyRuntimeEnabled: Bool private var capabilityValidUntil: [String: TimeInterval] = [:] private var uploadTasks: [String: Task] = [:] private var uploadRunIDs: [String: UUID] = [:] @@ -115,7 +116,8 @@ final class DiagnosticsPairingController { }, uploadSleep: @escaping @Sendable (UInt64) async throws -> Void = { try await ContinuousClock().sleep(for: .seconds(Int64($0))) - } + }, + receiveSideReadOnlyRuntimeEnabled: Bool = ConflictSafetyPolicy.receiveSideReadOnlyRuntimeEnabled ) { self.credentialStore = credentialStore self.transportFactory = transportFactory @@ -124,6 +126,7 @@ final class DiagnosticsPairingController { self.uploadRandomBytes = uploadRandomBytes self.uploadFileWriter = uploadFileWriter self.uploadSleep = uploadSleep + self.receiveSideReadOnlyRuntimeEnabled = receiveSideReadOnlyRuntimeEnabled } func refresh() { @@ -392,10 +395,17 @@ final class DiagnosticsPairingController { func beginForegroundUpload( recordID: String, + receiveSafetyState: @MainActor () -> ConflictSafetyPolicy.State, preflight: @escaping UploadPreflightProvider, rescan: @escaping UploadRescan, events: @escaping UploadEventsProvider ) { + guard !receiveSideReadOnlyRuntimeEnabled, + receiveSafetyState() == .clear else { + lastError = .unavailable + uploadStatuses[recordID] = UploadStatus(phase: .unavailable) + return + } guard uploadTasks[recordID] == nil else { return } lastError = nil uploadStatuses[recordID] = UploadStatus(phase: .preflighting) diff --git a/ios/VaultSync/Services/FolderPathReconciler.swift b/ios/VaultSync/Services/FolderPathReconciler.swift index 98b1551..53a5db5 100644 --- a/ios/VaultSync/Services/FolderPathReconciler.swift +++ b/ios/VaultSync/Services/FolderPathReconciler.swift @@ -185,6 +185,21 @@ enum FolderPathReconciler { private struct BridgeFolder: Decodable { let id: String let path: String + let type: String? + } + + /// Only send-only folders may enter the mutating reconcile core in 2.0.2. + /// Receive-capable and unknown future types are omitted before reading or + /// writing the sidecar, probing paths, or calling `SetFolderPath` (#150). + static func liveReconcileCandidates( + _ folders: [(id: String, path: String, type: String?)] + ) -> [(id: String, path: String)] { + folders.compactMap { folder in + guard ConflictSafetyPolicy.runtimeState(forFolderType: folder.type) == .clear else { + return nil + } + return (id: folder.id, path: folder.path) + } } /// Read the live folder list from the bridge and reconcile their paths @@ -196,7 +211,9 @@ enum FolderPathReconciler { let decoded = try? JSONDecoder().decode([BridgeFolder].self, from: data) else { return } - let folders = decoded.map { (id: $0.id, path: $0.path) } + let folders = liveReconcileCandidates( + decoded.map { (id: $0.id, path: $0.path, type: $0.type) } + ) guard !folders.isEmpty else { return } let env = Environment( diff --git a/ios/VaultSync/Services/SyncBridgeService.swift b/ios/VaultSync/Services/SyncBridgeService.swift index 13bc742..08ec9c9 100644 --- a/ios/VaultSync/Services/SyncBridgeService.swift +++ b/ios/VaultSync/Services/SyncBridgeService.swift @@ -248,32 +248,49 @@ struct SyncBridgeService { BridgeGetConflictFilesJSON(folderID) } - /// Read a text file's content within a folder. relPath is relative to the folder root. - /// Returns `(content, nil)` on success, or `(nil, errorMessage)` on failure. - static func readFileContent(folderID: String, relPath: String) -> (content: String?, error: String?) { - let result = BridgeReadFileContent(folderID, relPath) - if result.hasPrefix("error:") { - return (nil, String(result.dropFirst(6))) + enum FileInspectionResult: Equatable, Sendable { + case content(String) + case unavailable + } + + private struct FileInspectionPayload: Decodable { + let content: String? + let error: String? + } + + /// Decodes the bridge's unambiguous inspection envelope. Unknown, legacy, + /// contradictory, or detailed errors fail closed to one generic state. + static func decodeFileInspectionResult(_ raw: String) -> FileInspectionResult { + guard let data = raw.data(using: .utf8), + let payload = try? JSONDecoder().decode(FileInspectionPayload.self, from: data), + payload.error == nil, + let content = payload.content else { + return .unavailable } - return (result, nil) + return .content(content) } - /// Resolve a sync conflict. If keepConflict is true, the conflict version replaces the original. - /// - Returns: nil on success, error message on failure. + /// Read a text file within a folder without exposing bridge/path detail. + static func readFileContent(folderID: String, relPath: String) -> FileInspectionResult { + decodeFileInspectionResult(BridgeReadFileContent(folderID, relPath)) + } + + /// ABI-compatible inspection-only recovery stub. The current bridge always + /// returns the stable path-free recovery-unavailable error. static func resolveConflict(folderID: String, conflictFileName: String, keepConflict: Bool) -> String? { let result = BridgeResolveConflict(folderID, conflictFileName, keepConflict) return result.isEmpty ? nil : result } - /// Keep both versions by renaming the conflict file to a non-conflict name. - /// - Returns: nil on success, error message on failure. + /// ABI-compatible inspection-only recovery stub. No name is derived and no + /// filesystem operation is performed by the current bridge. static func keepBothConflict(folderID: String, conflictFileName: String) -> String? { let result = BridgeKeepBothConflict(folderID, conflictFileName) return result.isEmpty ? nil : result } - /// Remove every sync-conflict copy of the file at originalPath inside the folder. - /// Returns `(removed, nil)` on success or `(0, errorMessage)` on failure. + /// ABI-compatible inspection-only recovery stub. The current bridge returns + /// zero removals and the stable path-free recovery-unavailable error. static func removeConflictFilesForOriginal(folderID: String, originalPath: String) -> (removed: Int, error: String?) { let raw = BridgeRemoveConflictFilesForOriginal(folderID, originalPath) struct Payload: Decodable { @@ -314,11 +331,10 @@ struct SyncBridgeService { BridgeGetPendingFoldersJSON() } - /// Accept a pending folder offer by creating it locally and sharing with offering devices. - /// `allowNonEmpty` carries the user's explicit merge confirmation through to - /// the Go hard floor, which otherwise refuses a target directory that - /// already holds content (#54) — pass false unless the user confirmed. - /// - Returns: nil on success, error message on failure. + /// Stable wrapper for the retained gomobile ABI. Pending-share acceptance + /// is unavailable in 2.0.2, and no shipping Swift flow calls this wrapper + /// (#150). Do not use it to bypass the inspection-only policy; future live + /// wiring requires a separately approved recovery doctrine. static func acceptPendingFolder(folderID: String, label: String, path: String, allowNonEmpty: Bool) -> String? { let result = BridgeAcceptPendingFolder(folderID, label, path, allowNonEmpty) return result.isEmpty ? nil : result diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index 02365d4..fcdde74 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -131,6 +131,7 @@ final class SyncthingManager { private(set) var folders: [FolderInfo] = [] private(set) var folderStatuses: [String: FolderStatusInfo] = [:] private(set) var conflictFiles: [String: [ConflictInfo]] = [:] + private(set) var conflictInspectionUnavailableFolderIDs: Set = [] private(set) var pendingFolders: [PendingFolderInfo] = [] private(set) var ignoredPendingFolderIDs: Set = [] /// Folder IDs the user removed on this iPhone. While a peer still shares @@ -157,7 +158,6 @@ final class SyncthingManager { } private var pollTask: Task? - private var rescanTask: Task? /// True once this externally initiated engine generation has used its one /// automatic restart after a detected engine death (#61). Reset by every /// external lifecycle transition (`stop`, `resetForRestart`, @@ -183,18 +183,6 @@ final class SyncthingManager { private static let maxSyncActivityItems = 120 private static let maxFileEventsPerFolderPerPoll = 6 - /// Migration-safe silent auto-apply patterns. Hard-coded to the historical - /// set so future changes to `IgnorePreset.recommended` (which can grow or - /// shrink over time) do not silently mutate `.stignore` on existing vaults - /// during startup auto-merge. The first-run recommendation sheet uses - /// `IgnorePreset.recommended` separately for UI defaults — see - /// `SyncFilterRecommendationSheet`. - private nonisolated static let defaultIgnorePatterns: [String] = [ - ".Trash", - ".obsidian/workspace.json", - ".obsidian/workspace-mobile.json", - ] - /// Retired preference key from the former automatic last-writer-wins /// resolver. Keep the key stable and leave existing values untouched so an /// upgrade never rewrites user preferences as part of this safety change. @@ -210,7 +198,6 @@ final class SyncthingManager { false } - private var hasAppliedStartupIgnores = false private var activeWidgetSyncStart: Date? private var activeWidgetSyncFilesSynced = 0 private var lastWidgetSyncCompletionTime: Date? @@ -317,6 +304,44 @@ final class SyncthingManager { var id: String { conflictPath } } + struct ConflictInspectionSnapshot: Sendable { + let conflicts: [String: [ConflictInfo]] + let unavailableFolderIDs: Set + } + + /// A verified empty JSON array may remove cached conflicts. Any missing, + /// malformed, or explicitly unavailable response preserves the last + /// reviewable copies for that active folder and records incomplete + /// evidence instead of claiming that no conflicts exist (#150). + nonisolated static func mergeConflictInspection( + previous: [String: [ConflictInfo]], + activeFolderIDs: [String], + rawByFolder: [String: String] + ) -> ConflictInspectionSnapshot { + let activeIDs = Set(activeFolderIDs) + var conflicts: [String: [ConflictInfo]] = [:] + var unavailable: Set = [] + + for folderID in activeIDs.sorted() { + guard let raw = rawByFolder[folderID], + let data = raw.data(using: .utf8), + let decoded = try? JSONDecoder().decode([ConflictInfo].self, from: data) else { + if let retained = previous[folderID], !retained.isEmpty { + conflicts[folderID] = retained + } + unavailable.insert(folderID) + continue + } + if !decoded.isEmpty { + conflicts[folderID] = decoded + } + } + return ConflictInspectionSnapshot( + conflicts: conflicts, + unavailableFolderIDs: unavailable + ) + } + struct PendingFolderInfo: Codable, Identifiable, Hashable, Sendable { let id: String let label: String @@ -338,6 +363,7 @@ final class SyncthingManager { enum Kind: String, Sendable { case pathCollision case nestedFolders + case conflictRetentionSafety case folderErrors case disconnectedPeers case pendingShares @@ -426,6 +452,93 @@ final class SyncthingManager { .sorted() } + nonisolated static func conflictSafetyState( + for status: FolderStatusInfo? + ) -> ConflictSafetyPolicy.State { + guard let status else { + return .unknown + } + return ConflictSafetyPolicy.classify( + statusReadable: true, + state: status.state, + errorReason: status.errorReason, + hasRawErrorDetail: status.errorMessage?.isEmpty == false || status.errorPath?.isEmpty == false + ) + } + + /// Combines the immutable 2.0.2 folder-mode policy with live engine + /// evidence for receive-capable and unknown modes. Known SendOnly folders + /// retain their normal diagnostics; only fixed #150 reasons override that + /// mode globally. + nonisolated static func effectiveConflictSafetyState( + folderType: String?, + status: FolderStatusInfo? + ) -> ConflictSafetyPolicy.State { + let runtimeState = ConflictSafetyPolicy.runtimeState(forFolderType: folderType) + if runtimeState == .clear { + return ConflictSafetyPolicy.state(forEventReason: status?.errorReason) ?? .clear + } + return ConflictSafetyPolicy.aggregate([ + runtimeState, + conflictSafetyState(for: status), + ]) + } + + nonisolated static func conflictMutationBlockCode( + folderType: String? = nil, + cachedStatus: FolderStatusInfo?, + liveStatusJSON: String + ) -> String? { + let cachedState = effectiveConflictSafetyState( + folderType: folderType, + status: cachedStatus + ) + if let cachedCode = ConflictSafetyPolicy.actionErrorCode(for: cachedState) { + return cachedCode + } + + guard let data = liveStatusJSON.data(using: .utf8), + let liveStatus = try? JSONDecoder().decode(SyncBridgeService.FolderStatusPayload.self, from: data) else { + return ConflictSafetyPolicy.actionErrorCode( + for: ConflictSafetyPolicy.runtimeState(forFolderType: folderType) + ) + } + let liveState = effectiveConflictSafetyState( + folderType: folderType, + status: FolderStatusInfo(payload: liveStatus) + ) + return ConflictSafetyPolicy.actionErrorCode(for: liveState) + } + + func conflictSafetyState(folderID: String) -> ConflictSafetyPolicy.State { + let folderType = folders.first(where: { $0.id == folderID })?.type + return Self.effectiveConflictSafetyState( + folderType: folderType, + status: folderStatuses[folderID] + ) + } + + /// Folder mode and live engine evidence jointly authorize receive-side + /// work. A missing status after a restart remains unknown for an unknown + /// mode, and every receive-capable mode remains stopped even when the + /// bridge reports a clear-shaped status. SendOnly keeps its normal status + /// diagnostics unless the bridge supplies a fixed #150 reason. + var conflictRetentionSafetyFolderIDs: [String] { + folders.compactMap { folder in + conflictSafetyState(folderID: folder.id) == .stopped ? folder.id : nil + }.sorted() + } + + var conflictSafetyUnknownFolderIDs: [String] { + folders.compactMap { folder in + conflictSafetyState(folderID: folder.id) == .unknown ? folder.id : nil + }.sorted() + } + + var conflictSafetyBlockedFolderIDs: [String] { + Array(Set(conflictRetentionSafetyFolderIDs).union(conflictSafetyUnknownFolderIDs)).sorted() + } + /// When a device's reconnect grace window ends. Disconnects first observed /// within `startupWindow` of an engine start get the longer /// `startupGracePeriod` (measured from engine start); everything else gets @@ -514,7 +627,7 @@ final class SyncthingManager { kind: .pathCollision, title: L10n.tr("Two Vaults Are Sharing One Folder"), message: L10n.tr("Two or more vaults sync into the same local folder, so their contents are being mixed together. The affected vaults have been paused to stop further damage."), - remediation: L10n.tr("Remove an affected vault on this iPhone, then accept it again under Pending Shares — it moves into its own folder. If the files are already mixed, restore the clean copy on your computer first."), + remediation: L10n.tr("Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version."), severity: .critical, count: affectedCount, folderID: collisionGroups.flatMap { $0 }.min(), @@ -539,7 +652,7 @@ final class SyncthingManager { kind: .nestedFolders, title: L10n.tr("One Vault Is Nested Inside Another"), message: L10n.tr("A vault's folder is inside another vault's folder, so the outer vault syncs the inner vault's notes to its own devices. The affected vaults have been paused to stop further mixing."), - remediation: L10n.tr("Select the folder that contains your vaults (\"On My iPhone\" → \"Obsidian\") as VaultSync's Obsidian directory, then remove the inner vault on this iPhone and accept it again under Pending Shares — it gets its own folder. Only afterwards, delete the leftover copy inside the outer vault on your other devices."), + remediation: L10n.tr("Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version."), severity: .critical, count: nestedIDs.count, folderID: nestedIDs.min(), @@ -548,12 +661,36 @@ final class SyncthingManager { ) } + let conflictSafetyBlockedIDs = conflictSafetyBlockedFolderIDs + let specificIntegrityErrorIDs = Set(conflictSafetyBlockedIDs.filter { + recognizableProtectedIntegrityError(folderID: $0) != nil + }) + for folderID in conflictSafetyBlockedIDs where !specificIntegrityErrorIDs.contains(folderID) { + let state = conflictSafetyState(folderID: folderID) + let safetyError = SyncUserError.conflictSafetyError(for: state) + issues.append( + SyncIssueItem( + kind: .conflictRetentionSafety, + title: safetyError.title, + message: safetyError.message, + remediation: safetyError.remediation, + severity: .critical, + count: 1, + folderID: folderID, + deviceID: nil + ) + ) + } + // Folders stuck on a stale/inaccessible path are surfaced by their own // guided "remove / reconnect" card, so exclude them here to avoid // double-listing them with the generic (and, for them, useless) // "rescan failed vaults" remediation. let unreachableIDs = Set(unreachableFolders.map(\.id)) - let erroredFolderIDs = folderIDsWithErrors.filter { !unreachableIDs.contains($0) } + let erroredFolderIDs = folderIDsWithErrors.filter { + !unreachableIDs.contains($0) + && (!conflictSafetyBlockedIDs.contains($0) || specificIntegrityErrorIDs.contains($0)) + } if !erroredFolderIDs.isEmpty { let count = erroredFolderIDs.count issues.append( @@ -591,37 +728,42 @@ final class SyncthingManager { ) } - let pendingCount = actionablePendingFolders.count - if pendingCount > 0 { + // Conflict review remains read-only and available even when the folder + // is safety-stopped or its status evidence is incomplete (#150). + let reviewableConflictFiles = conflictFiles.filter { !$0.value.isEmpty } + let reviewableConflictCount = reviewableConflictFiles.values.reduce(0) { + $0 + Set($1.map(\.originalPath)).count + } + if reviewableConflictCount > 0 { + let firstFolderID = reviewableConflictFiles + .sorted(by: { $0.key < $1.key }) + .first? + .key issues.append( SyncIssueItem( - kind: .pendingShares, - title: pendingCount == 1 ? L10n.tr("1 Pending Share Needs Attention") : L10n.fmt("%d Pending Shares Need Attention", pendingCount), - message: L10n.tr("Pending shares are waiting to be accepted before sync can start."), - remediation: L10n.tr("Accept a share to activate syncing for that vault."), + kind: .conflicts, + title: reviewableConflictCount == 1 ? L10n.tr("1 Conflict Available for Review") : L10n.fmt("%d Conflicts Available for Review", reviewableConflictCount), + message: L10n.tr("A separate conflict copy was detected for a file."), + remediation: L10n.tr("Open conflicts to see which copies are still available. Recovery actions are unavailable."), severity: .warning, - count: pendingCount, - folderID: actionablePendingFolders.first?.id, - deviceID: actionablePendingFolders.first?.offeredBy.first?.deviceID + count: reviewableConflictCount, + folderID: firstFolderID, + deviceID: nil ) ) } - if unresolvedConflictCount > 0 { - let firstFolderID = conflictFiles - .filter { !$0.value.isEmpty } - .sorted(by: { $0.key < $1.key }) - .first? - .key + if !conflictInspectionUnavailableFolderIDs.isEmpty { + let count = conflictInspectionUnavailableFolderIDs.count issues.append( SyncIssueItem( kind: .conflicts, - title: unresolvedConflictCount == 1 ? L10n.tr("1 Conflict Needs Resolution") : L10n.fmt("%d Conflicts Need Resolution", unresolvedConflictCount), - message: L10n.tr("Conflicts mean multiple versions exist and need a manual decision."), - remediation: L10n.tr("Open conflicts and choose which version to keep."), + title: L10n.tr("Conflict Inspection Unavailable"), + message: L10n.tr("VaultSync cannot verify whether the conflict list is complete."), + remediation: L10n.tr("Open conflicts to review any previously visible copies. No recovery action is available."), severity: .warning, - count: unresolvedConflictCount, - folderID: firstFolderID, + count: count, + folderID: conflictInspectionUnavailableFolderIDs.sorted().first, deviceID: nil ) ) @@ -655,11 +797,11 @@ final class SyncthingManager { let severity: SyncIssueSeverity switch outcome.result { - case .bridgeStartFailed, .noBookmarkAccess, .failed: + case .bridgeStartFailed, .noBookmarkAccess, .failed, .settledWithFolderError: severity = .critical case .noFoldersConfigured, .notIdleBeforeDeadline: severity = .warning - case .synced, .alreadyIdle, .settledWithFolderError: + case .synced, .alreadyIdle: return nil } @@ -832,6 +974,7 @@ final class SyncthingManager { folders = [] folderStatuses = [:] conflictFiles = [:] + conflictInspectionUnavailableFolderIDs = [] pendingFolders = [] // New generation: accepts hold until the next start's reconcile // completes, and a still-running reconcile's late outcome is ignored @@ -866,19 +1009,11 @@ final class SyncthingManager { // MARK: - Folder management - /// Add a new folder. + /// Retained facade for the existing add-folder API. The bridge creates a + /// receive-capable folder, which is read-only in 2.0.2, so return before + /// bridge, refresh, marker, scan, or persistence work (#150). func addFolder(id: String, label: String, path: String) -> String? { - let result = SyncBridgeService.addFolder(id: id, label: label, path: path) - if result == nil { - refreshFolders() - let folderID = id - Task.detached { - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - Self.applyDefaultIgnoresIfNeeded(folderID: folderID) - } - } - return result + "vaultsync-conflict-retention-safety-stop" } /// Remove a folder by ID. @@ -927,7 +1062,95 @@ final class SyncthingManager { /// Trigger a rescan of a folder. func rescanFolder(id: String) -> String? { - SyncBridgeService.rescanFolder(folderID: id) + if let errorCode = conflictMutationBlockCode(folderID: id) { + return errorCode + } + return SyncBridgeService.rescanFolder(folderID: id) + } + + enum ForegroundRescanResult: Equatable, Sendable { + case triggered + case blocked(String) + case failed(String) + } + + nonisolated static func defaultForegroundRescanTargetFolderIDs( + _ configuredFolders: [FolderInfo] + ) -> [String] { + configuredFolders.compactMap { folder in + ConflictSafetyPolicy.runtimeState(forFolderType: folder.type) == .clear + ? folder.id + : nil + }.sorted() + } + + var foregroundRescanEligibleFolderIDs: [String] { + Self.defaultForegroundRescanTargetFolderIDs(folders) + } + + /// Runs a complete read-only safety pass before the first foreground + /// rescan, then rechecks the exact folder at its mutation gate (#150). + nonisolated static func performForegroundRescans( + configuredFolders: [FolderInfo], + targetFolderIDs: [String], + statusJSON: (_ folderID: String) -> String, + rescan: (_ folderID: String) -> String? + ) -> ForegroundRescanResult { + let configuredIDs = configuredFolders.map(\.id) + guard !targetFolderIDs.isEmpty, + Set(configuredIDs).count == configuredIDs.count, + Set(targetFolderIDs).count == targetFolderIDs.count, + configuredIDs.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + targetFolderIDs.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + return .blocked(ConflictSafetyPolicy.engineStopMarker) + } + let foldersByID = Dictionary(uniqueKeysWithValues: configuredFolders.map { ($0.id, $0) }) + guard targetFolderIDs.allSatisfy({ foldersByID[$0] != nil }) else { + return .blocked(ConflictSafetyPolicy.engineStopMarker) + } + let orderedFolderIDs = targetFolderIDs.sorted() + + // Folder type is checked before the first status read or bridge call. + // Receive-capable targets stop the entire requested operation, while a + // receive sibling cannot suppress an explicitly send-only request. + for folderID in orderedFolderIDs { + let runtimeState = ConflictSafetyPolicy.runtimeState( + forFolderType: foldersByID[folderID]?.type + ) + if let code = ConflictSafetyPolicy.actionErrorCode(for: runtimeState) { + return .blocked(code) + } + } + + func blockCode(folderID: String) -> String? { + let encodedStatus = statusJSON(folderID) + guard let data = encodedStatus.data(using: .utf8), + let status = try? JSONDecoder().decode(SyncBridgeService.FolderStatusPayload.self, from: data) else { + // The configured type is the SendOnly authorization boundary. + // Ordinary or temporarily unreadable status must not disable + // that mode's established repair rescan; the bridge repeats + // the hard-floor check at the mutation ABI. + return nil + } + let state = ConflictSafetyPolicy.state(forEventReason: status.errorReason) ?? .clear + return ConflictSafetyPolicy.actionErrorCode(for: state) + } + + for folderID in orderedFolderIDs { + if let code = blockCode(folderID: folderID) { + return .blocked(code) + } + } + + for folderID in orderedFolderIDs { + if let code = blockCode(folderID: folderID) { + return .blocked(code) + } + if let error = rescan(folderID) { + return .failed(error) + } + } + return .triggered } /// Trigger a foreground sync using the same rescan path as the main UI. @@ -943,6 +1166,17 @@ final class SyncthingManager { } } + func triggerForegroundSync(folderIDs: [String]) { + guard !isAnySyncing else { + logger.info("Ignoring sync request because a sync is already in progress") + return + } + + Task { + await performForegroundSyncRequest(folderIDs: folderIDs) + } + } + /// Async variant of `triggerForegroundSync` for callers that want to await /// completion — e.g. SwiftUI `.refreshable`, where the spinner should stay /// visible until the trigger has actually landed in the bridge. @@ -956,62 +1190,37 @@ final class SyncthingManager { // MARK: - Conflict management - /// Resolve a conflict file. Returns nil on success. - func resolveConflict(folderID: String, conflictFileName: String, keepConflict: Bool) -> String? { - let result = SyncBridgeService.resolveConflict( - folderID: folderID, - conflictFileName: conflictFileName, - keepConflict: keepConflict + private func conflictMutationBlockCode(folderID: String) -> String? { + let folderType = folders.first(where: { $0.id == folderID })?.type + return Self.conflictMutationBlockCode( + folderType: folderType, + cachedStatus: folderStatuses[folderID], + liveStatusJSON: SyncBridgeService.getFolderStatusJSON(folderID: folderID) ) - if result == nil { - refreshConflicts() - } - return result } - /// Keep both versions by renaming the conflict file. Returns (nil, newPath) on success, or (error, nil) on failure. + /// Retained Swift facade for the stable gomobile conflict ABI. + /// + /// Conflict recovery is intentionally unavailable in 2.0.2. Return the + /// fixed path-free error before reading status or reaching an older + /// framework that may still contain the retired mutating implementation. + func resolveConflict(folderID: String, conflictFileName: String, keepConflict: Bool) -> String? { + "vaultsync-conflict-recovery-unavailable" + } + + /// Retained Swift facade for the stable gomobile conflict ABI. No name is + /// derived and no bridge call is made while recovery is unavailable. func keepBothConflict(folderID: String, conflict: ConflictInfo) -> (error: String?, newPath: String?) { - let result = SyncBridgeService.keepBothConflict(folderID: folderID, conflictFileName: conflict.conflictPath) - if result == nil { - refreshConflicts() - let url = URL(fileURLWithPath: conflict.originalPath) - let ext = url.pathExtension - let base = url.deletingPathExtension().path - let newPath = ext.isEmpty ? "\(base).conflict-\(conflict.deviceShortID)" : "\(base).conflict-\(conflict.deviceShortID).\(ext)" - return (nil, newPath) - } - return (result, nil) + ("vaultsync-conflict-recovery-unavailable", nil) } // MARK: - Pending folder shares - /// Accept a pending folder offer. Creates the folder locally and shares with offering devices. - /// `allowNonEmpty` carries the user's explicit merge confirmation (or a - /// recorded manual target, #52) through to the Go hard floor (#54). + /// Retained facade for pending-share ABI compatibility. Every accepted + /// offer is receive-capable, so 2.0.2 returns before bridge, folder-list, + /// removed-state, sidecar, scan, or persistence work (#150). func acceptPendingFolder(folderID: String, label: String, path: String, allowNonEmpty: Bool) -> String? { - let result = SyncBridgeService.acceptPendingFolder(folderID: folderID, label: label, path: path, allowNonEmpty: allowNonEmpty) - if result == nil { - // An explicit accept supersedes an earlier removal — lift the - // auto-accept suppression for this folder ID (#52). - if userRemovedFolderIDs.contains(folderID) { - userRemovedFolderIDs.remove(folderID) - persistUserRemovedFolderIDs() - } - refreshFolders() - refreshPendingFolders() - // Trigger a rescan after a short delay to kick-start initial sync. - // The delay gives Syncthing time to initialize the folder model - // before we request the scan. - let id = folderID - rescanTask?.cancel() - rescanTask = Task.detached { - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - Self.applyDefaultIgnoresIfNeeded(folderID: id) - _ = SyncBridgeService.rescanFolder(folderID: id) - } - } - return result + "vaultsync-conflict-retention-safety-stop" } // MARK: - Device rename @@ -1038,6 +1247,7 @@ final class SyncthingManager { folders = [] folderStatuses = [:] conflictFiles = [:] + conflictInspectionUnavailableFolderIDs = [] pendingFolders = [] // New generation, same as stop(): the restarted engine's paths count // as unsettled until its own reconcile completes (#56). @@ -1090,27 +1300,6 @@ final class SyncthingManager { } } - // MARK: - Default ignore patterns - - /// Apply default .stignore patterns for an Obsidian vault folder. - /// - /// Delegates the read-merge-write to the Go bridge's `EnsureDefaultIgnores`, - /// which distinguishes "no .stignore yet" (safe to create) from "could not - /// read .stignore" (transient error) and aborts on the latter. A naive - /// Swift-side read could see a momentary empty/unreadable result and - /// overwrite a populated `.stignore` with just the defaults — this avoids - /// that data-loss path entirely. - /// `nonisolated` so callers can run the bridge read-merge-write off the main - /// actor — it touches no main-actor state, only the bridge and the logger. - private nonisolated static func applyDefaultIgnoresIfNeeded(folderID: String) { - guard let data = try? JSONEncoder().encode(defaultIgnorePatterns), - let json = String(data: data, encoding: .utf8) else { return } - - if SyncBridgeService.ensureDefaultIgnores(folderID: folderID, defaultsJSON: json) != nil { - logger.warning("Failed to ensure default ignore rules") - } - } - // MARK: - Private private func startPolling() { @@ -1161,18 +1350,6 @@ final class SyncthingManager { applyDeviceList(decoded) } - // One-time check: apply default .stignore patterns for existing folders. - if !hasAppliedStartupIgnores && !folders.isEmpty { - hasAppliedStartupIgnores = true - let folderIDs = folders.map(\.id) - Task.detached { - try? await Task.sleep(for: .seconds(3)) - for id in folderIDs { - Self.applyDefaultIgnoresIfNeeded(folderID: id) - } - } - } - if let data = snapshot.2.data(using: .utf8), let decoded = try? JSONDecoder().decode([PendingFolderInfo].self, from: data) { pendingFolders = decoded @@ -1199,31 +1376,36 @@ final class SyncthingManager { return (device.deviceID, displayName) } ) - appendActivityEvents( - bridgeEvents, - folderNamesByID: folderNameByID, - deviceNamesByID: deviceNameByID - ) - // Folder statuses + conflicts need the current folder list. let currentFolders = folders let statusSnapshot = await Task.detached { var statuses: [String: FolderStatusInfo] = [:] - var conflicts: [String: Data] = [:] + var conflicts: [String: String] = [:] for folder in currentFolders { if let status = SyncBridgeService.getFolderStatus(folderID: folder.id) { statuses[folder.id] = FolderStatusInfo(payload: status) } - let cJSON = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) - if let d = cJSON.data(using: .utf8) { - conflicts[folder.id] = d - } + conflicts[folder.id] = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) } return (statuses, conflicts) }.value let previousStatuses = folderStatuses let newStatuses = statusSnapshot.0 + let safetyStates = Dictionary( + uniqueKeysWithValues: currentFolders.map { + ($0.id, Self.effectiveConflictSafetyState( + folderType: $0.type, + status: newStatuses[$0.id] + )) + } + ) + appendActivityEvents( + bridgeEvents, + folderNamesByID: folderNameByID, + deviceNamesByID: deviceNameByID, + folderSafetyStates: safetyStates + ) updateWidgetSyncMetrics(previousStatuses: previousStatuses, newStatuses: newStatuses) updateSyncHistory( newStatuses: newStatuses, @@ -1235,22 +1417,22 @@ final class SyncthingManager { ) folderStatuses = newStatuses - var newConflicts: [String: [ConflictInfo]] = [:] - for (id, data) in statusSnapshot.1 { - if let decoded = try? JSONDecoder().decode([ConflictInfo].self, from: data), !decoded.isEmpty { - newConflicts[id] = decoded - } + let conflictSnapshot = Self.mergeConflictInspection( + previous: conflictFiles, + activeFolderIDs: currentFolders.map(\.id), + rawByFolder: statusSnapshot.1 + ) + conflictFiles = conflictSnapshot.conflicts + conflictInspectionUnavailableFolderIDs = conflictSnapshot.unavailableFolderIDs + if conflictSnapshot.unavailableFolderIDs.isEmpty { + BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) } - conflictFiles = newConflicts - BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) writeWidgetSnapshotIfNeeded() } private func stopPolling() { pollTask?.cancel() pollTask = nil - rescanTask?.cancel() - rescanTask = nil } private func refreshBackgroundSyncOutcome() { @@ -1345,19 +1527,21 @@ final class SyncthingManager { } private func refreshConflicts() { - var allConflicts: [String: [ConflictInfo]] = [:] - for folder in folders { - let json = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) - guard let data = json.data(using: .utf8), - let decoded = try? JSONDecoder().decode([ConflictInfo].self, from: data) else { - continue - } - if !decoded.isEmpty { - allConflicts[folder.id] = decoded + let rawByFolder = Dictionary( + uniqueKeysWithValues: folders.map { + ($0.id, SyncBridgeService.getConflictFilesJSON(folderID: $0.id)) } + ) + let snapshot = Self.mergeConflictInspection( + previous: conflictFiles, + activeFolderIDs: folders.map(\.id), + rawByFolder: rawByFolder + ) + conflictFiles = snapshot.conflicts + conflictInspectionUnavailableFolderIDs = snapshot.unavailableFolderIDs + if snapshot.unavailableFolderIDs.isEmpty { + BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) } - conflictFiles = allConflicts - BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) } private func refreshPendingFolders() { @@ -1384,7 +1568,8 @@ final class SyncthingManager { private func appendActivityEvents( _ bridgeEvents: [BridgeEventInfo], folderNamesByID: [String: String], - deviceNamesByID: [String: String] + deviceNamesByID: [String: String], + folderSafetyStates: [String: ConflictSafetyPolicy.State] ) { guard !bridgeEvents.isEmpty else { return } @@ -1398,7 +1583,8 @@ final class SyncthingManager { guard let item = makeSyncEventItem( from: event, folderNamesByID: folderNamesByID, - deviceNamesByID: deviceNamesByID + deviceNamesByID: deviceNamesByID, + folderSafetyStates: folderSafetyStates ) else { continue } @@ -1464,11 +1650,25 @@ final class SyncthingManager { private func makeSyncEventItem( from event: BridgeEventInfo, folderNamesByID: [String: String], - deviceNamesByID: [String: String] + deviceNamesByID: [String: String], + folderSafetyStates: [String: ConflictSafetyPolicy.State] = [:] ) -> SyncEventItem? { let data = event.data ?? [:] let timestamp = parseBridgeDate(event.time) ?? Date() + // The fixed reason wins before event type, raw state, folder/name/path, + // or success handling. In particular, pathless ItemFinished safety + // events must never disappear or become a successful file event. + if let safetyState = ConflictSafetyPolicy.state(forEventReason: data["reason"]) { + return conflictSafetyEvent(id: event.id, date: timestamp, state: safetyState) + } + if let folderID = data["folder"], + let safetyState = folderSafetyStates[folderID], + safetyState != .clear, + event.type == "StateChanged" || event.type == "ItemFinished" || event.type == "FolderErrors" { + return conflictSafetyEvent(id: event.id, date: timestamp, state: safetyState) + } + switch event.type { case "StateChanged": let folderID = data["folder"] @@ -1623,6 +1823,24 @@ final class SyncthingManager { } } + private func conflictSafetyEvent( + id: Int, + date: Date, + state: ConflictSafetyPolicy.State + ) -> SyncEventItem { + let safetyError = SyncUserError.conflictSafetyError(for: state) + return SyncEventItem( + id: id, + kind: .folderError, + date: date, + title: safetyError.title, + detail: safetyError.message, + folderID: nil, + deviceID: nil, + filePath: nil + ) + } + private func isDuplicateActivity(_ item: SyncEventItem) -> Bool { let fingerprint = [ item.kind.rawValue, @@ -1697,15 +1915,21 @@ final class SyncthingManager { foldersWithConnectedPeer: Set ) { var didChange = false + let folderTypesByID = Dictionary(uniqueKeysWithValues: folders.map { ($0.id, $0.type) }) for (folderID, status) in newStatuses { let previousState = previousFolderStates[folderID] let hasConnectedPeer = foldersWithConnectedPeer.contains(folderID) + let safetyState = Self.effectiveConflictSafetyState( + folderType: folderTypesByID[folderID], + status: status + ) if Self.didTransitionToSuccessfulIdle( previousState: previousState, status: status, - hasConnectedPeer: hasConnectedPeer + hasConnectedPeer: hasConnectedPeer, + safetyState: safetyState ) { if upsertLastSyncDate(folderID: folderID, date: Date()) { didChange = true @@ -1716,7 +1940,8 @@ final class SyncthingManager { status: status, stateChangedAt: parseBridgeDate(status.stateChanged), existingDate: lastSyncTimeByFolder[folderID], - hasConnectedPeer: hasConnectedPeer + hasConnectedPeer: hasConnectedPeer, + safetyState: safetyState ), let changedAt = parseBridgeDate(status.stateChanged), upsertLastSyncDate(folderID: folderID, date: changedAt) { @@ -1752,11 +1977,13 @@ final class SyncthingManager { nonisolated static func didTransitionToSuccessfulIdle( previousState: String?, status: FolderStatusInfo, - hasConnectedPeer: Bool + hasConnectedPeer: Bool, + safetyState: ConflictSafetyPolicy.State? = nil ) -> Bool { guard let previousState else { return false } let wasActive = previousState == "syncing" || previousState == "scanning" guard wasActive, status.state == "idle" else { return false } + guard (safetyState ?? conflictSafetyState(for: status)) == .clear else { return false } guard hasConnectedPeer else { return false } return status.needFiles == 0 && status.errorMessage == nil } @@ -1769,9 +1996,11 @@ final class SyncthingManager { status: FolderStatusInfo, stateChangedAt: Date?, existingDate: Date?, - hasConnectedPeer: Bool + hasConnectedPeer: Bool, + safetyState: ConflictSafetyPolicy.State? = nil ) -> Bool { guard status.state == "idle" else { return false } + guard (safetyState ?? conflictSafetyState(for: status)) == .clear else { return false } guard status.needFiles == 0 else { return false } guard status.errorMessage == nil else { return false } guard hasConnectedPeer else { return false } @@ -1822,26 +2051,40 @@ final class SyncthingManager { } private func performForegroundSyncRequest(folderID: String?) async { + await performForegroundSyncRequest(folderIDs: folderID.map { [$0] }) + } + + private func performForegroundSyncRequest(folderIDs requestedFolderIDs: [String]?) async { if !isRunning { await start() } - let normalizedFolderID = folderID?.trimmingCharacters(in: .whitespacesAndNewlines) let availableFolders = await waitForFoldersForSyncRequest(maxWait: 3) let targetFolderIDs: [String] - if let normalizedFolderID, !normalizedFolderID.isEmpty { - guard availableFolders.contains(where: { $0.id == normalizedFolderID }) else { - logger.warning("Ignoring sync request for an unknown folder") + if let requestedFolderIDs { + let normalized = requestedFolderIDs.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + let availableIDs = Set(availableFolders.map(\.id)) + guard !normalized.isEmpty, + normalized.allSatisfy({ !$0.isEmpty }), + Set(normalized).count == normalized.count, + normalized.allSatisfy(availableIDs.contains) else { + logger.warning("Ignoring sync request with invalid folder selection") return } - targetFolderIDs = [normalizedFolderID] + targetFolderIDs = normalized.sorted() } else { guard !availableFolders.isEmpty else { logger.info("Ignoring sync request because no folders are configured") return } - targetFolderIDs = availableFolders.map(\.id) + targetFolderIDs = Self.defaultForegroundRescanTargetFolderIDs(availableFolders) + guard !targetFolderIDs.isEmpty else { + logger.info("Ignoring sync request because no SendOnly folders are mutable") + return + } } guard !isAnySyncing else { @@ -1849,35 +2092,35 @@ final class SyncthingManager { return } - beginWidgetSyncSessionIfNeeded(startDate: Date()) - - var didTriggerSync = false - var lastTriggerError: String? - - for id in Array(Set(targetFolderIDs)).sorted() { - if let err = rescanFolder(id: id) { - lastTriggerError = err - logger.error("Foreground sync trigger failed") - } else { - didTriggerSync = true - } - } + let triggerResult = Self.performForegroundRescans( + configuredFolders: availableFolders, + targetFolderIDs: targetFolderIDs, + statusJSON: { SyncBridgeService.getFolderStatusJSON(folderID: $0) }, + rescan: { SyncBridgeService.rescanFolder(folderID: $0) } + ) - if didTriggerSync { + switch triggerResult { + case .triggered: + beginWidgetSyncSessionIfNeeded(startDate: Date()) error = nil userError = nil writeWidgetSnapshotIfNeeded(statusOverride: .syncing) - return - } - - if let lastTriggerError { - error = lastTriggerError + case let .blocked(code): + error = code + userError = SyncUserError.from( + rawMessage: code, + fallbackTitle: L10n.tr("Could Not Start Sync") + ) + completeWidgetSyncSession(status: .error, completedAt: Date()) + case let .failed(triggerError): + logger.error("Foreground sync trigger failed") + error = triggerError userError = SyncUserError.from( - rawMessage: lastTriggerError, + rawMessage: triggerError, fallbackTitle: L10n.tr("Could Not Start Sync") ) + completeWidgetSyncSession(status: .error, completedAt: Date()) } - completeWidgetSyncSession(status: .error, completedAt: Date()) } private func waitForFoldersForSyncRequest(maxWait: TimeInterval) async -> [FolderInfo] { @@ -1905,6 +2148,19 @@ final class SyncthingManager { if isSyncingNow { beginWidgetSyncSessionIfNeeded(startDate: Date()) } else if wasSyncing || activeWidgetSyncStart != nil { + let safetyClear = folders.allSatisfy { + Self.effectiveConflictSafetyState( + folderType: $0.type, + status: newStatuses[$0.id] + ) == .clear + } + guard safetyClear else { + abandonWidgetSyncSession() + writeWidgetSnapshotIfNeeded( + statusOverride: currentWidgetSnapshotStatus(using: newStatuses) + ) + return + } // Close the session BEFORE deriving the tier: with the session // still open, the cascade reports .syncing and the completion // write persists a stale snapshot the poll-end write immediately @@ -1933,11 +2189,16 @@ final class SyncthingManager { activeWidgetSyncFilesSynced = 0 } + private func abandonWidgetSyncSession() { + activeWidgetSyncStart = nil + activeWidgetSyncFilesSynced = 0 + } + private func completeWidgetSyncSession( status: SyncStatus, - completedAt: Date + completedAt _: Date ) { - finalizeWidgetSyncSession(completedAt: completedAt) + abandonWidgetSyncSession() writeWidgetSnapshotIfNeeded(statusOverride: status) } @@ -1951,12 +2212,27 @@ final class SyncthingManager { private func currentWidgetSnapshotStatus( using statuses: [String: FolderStatusInfo]? = nil ) -> SyncStatus { + let usesFreshStatuses = statuses != nil let statuses = statuses ?? folderStatuses - var severities = unresolvedIssues.map(\.severity) + // During a poll, `unresolvedIssues` still reflects the previously + // published statuses. Replace only its safety tier with the supplied + // fresh snapshot so an old unknown cannot mask a newly verified clear + // status (and a new stop can never wait until the next write). + var severities = unresolvedIssues.compactMap { + usesFreshStatuses && $0.kind == .conflictRetentionSafety ? nil : $0.severity + } if statuses.values.contains(where: { $0.state == "error" }) { severities.append(.critical) } + if folders.contains(where: { + Self.effectiveConflictSafetyState( + folderType: $0.type, + status: statuses[$0.id] + ) != .clear + }) { + severities.append(.critical) + } return SyncHeaderModel.deriveWidgetStatus( hasEngineError: error != nil || userError != nil, @@ -2020,6 +2296,13 @@ final class SyncthingManager { } func folderUserError(folderID: String) -> SyncUserError? { + if let integrityError = recognizableProtectedIntegrityError(folderID: folderID) { + return integrityError + } + let safetyState = conflictSafetyState(folderID: folderID) + if safetyState != .clear { + return SyncUserError.conflictSafetyError(for: safetyState) + } guard let status = folderStatuses[folderID], status.state == "error" else { return nil } return SyncUserError.fromFolderStatus( reason: status.errorReason, @@ -2028,6 +2311,41 @@ final class SyncthingManager { ) } + private func recognizableProtectedIntegrityError(folderID: String) -> SyncUserError? { + let folderType = folders.first(where: { $0.id == folderID })?.type + guard ConflictSafetyPolicy.runtimeState(forFolderType: folderType) != .clear, + let status = folderStatuses[folderID], + status.state == "error" else { + return nil + } + + let mapped = SyncUserError.fromFolderStatus( + reason: status.errorReason, + message: status.errorMessage, + path: nil + ) + if mapped.category == .folderMarkerMissing { + return mapped + } + + let pathReasons: Set = [ + "permission_denied", + "folder_path_missing", + "folder_path_invalid", + "folder_path_unreadable", + ] + guard let reason = status.errorReason?.lowercased(), pathReasons.contains(reason) else { + return nil + } + return SyncUserError( + category: .fileAccess, + title: L10n.tr("Vault Folder Needs Manual Recovery"), + message: L10n.tr("VaultSync can no longer verify the configured vault folder."), + remediation: L10n.tr("Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically."), + technicalDetails: nil + ) + } + /// True when at least one errored folder could plausibly be helped by a /// rescan. A folder whose sync marker is gone cannot — Syncthing refuses /// to scan without the marker, by design — so when marker loss is the @@ -2035,7 +2353,12 @@ final class SyncthingManager { /// guidance (#65). var hasRescanableFolderErrors: Bool { folderIDsWithErrors.contains { id in - folderUserError(folderID: id)?.category != .folderMarkerMissing + guard let type = folders.first(where: { $0.id == id })?.type, + ConflictSafetyPolicy.runtimeState(forFolderType: type) == .clear else { + return false + } + guard let category = folderUserError(folderID: id)?.category else { return false } + return category != .folderMarkerMissing && category != .conflictRetentionSafetyStop } } @@ -2064,6 +2387,9 @@ final class SyncthingManager { ] let rel = FolderPathReconciler.loadRel() return folders.compactMap { folder in + guard ConflictSafetyPolicy.runtimeState(forFolderType: folder.type) == .clear else { + return nil + } guard let status = folderStatuses[folder.id], status.state == "error", let reason = status.errorReason, pathErrorReasons.contains(reason) else { return nil } @@ -2150,6 +2476,11 @@ final class SyncthingManager { SyncUserError.from(rawMessage: L10n.tr("Could not read current sync filters. Please try again.")) } + private func conflictMutationUserError(folderID: String) -> SyncUserError? { + guard let code = conflictMutationBlockCode(folderID: folderID) else { return nil } + return SyncUserError.from(rawMessage: code) + } + /// Read current `.stignore` lines for a folder. Display-friendly: returns /// an empty list if the bridge response cannot be parsed. Read-modify-write /// flows must use `readIgnorePatternsOrNil` instead. @@ -2164,6 +2495,9 @@ final class SyncthingManager { let json = String(data: data, encoding: .utf8) else { return SyncUserError.from(rawMessage: "encoding ignore patterns failed") } + if let safetyError = conflictMutationUserError(folderID: folderID) { + return safetyError + } if let err = SyncBridgeService.setFolderIgnores(folderID: folderID, ignoresJSON: json) { return SyncUserError.from(rawMessage: err) } @@ -2175,6 +2509,9 @@ final class SyncthingManager { /// unreadable bridge response can never wipe existing rules. @discardableResult func togglePreset(_ preset: IgnorePreset, folderID: String, enabled: Bool) -> SyncUserError? { + if let safetyError = conflictMutationUserError(folderID: folderID) { + return safetyError + } guard var current = readIgnorePatternsOrNil(folderID: folderID) else { return unreadableFiltersError() } @@ -2204,6 +2541,9 @@ final class SyncthingManager { /// bridge response can never wipe or reorder existing rules. @discardableResult func addIgnorePatterns(_ patterns: [String], folderID: String) -> SyncUserError? { + if let safetyError = conflictMutationUserError(folderID: folderID) { + return safetyError + } guard var current = readIgnorePatternsOrNil(folderID: folderID) else { return unreadableFiltersError() } @@ -2225,6 +2565,9 @@ final class SyncthingManager { /// semantically significant, e.g. for `!` un-ignore rules). @discardableResult func removeIgnorePatterns(_ patterns: [String], folderID: String) -> SyncUserError? { + if let safetyError = conflictMutationUserError(folderID: folderID) { + return safetyError + } guard var current = readIgnorePatternsOrNil(folderID: folderID) else { return unreadableFiltersError() } @@ -2248,6 +2591,9 @@ final class SyncthingManager { detectedPatterns: [String], enabledDetectedPatterns: Set ) -> SyncUserError? { + if let safetyError = conflictMutationUserError(folderID: folderID) { + return safetyError + } guard let existing = readIgnorePatternsOrNil(folderID: folderID) else { return unreadableFiltersError() } @@ -2314,55 +2660,16 @@ final class SyncthingManager { return "\(parent)/\(glob)" } - /// Perform the full "Always skip on this iPhone" action atomically: - /// 1. Add both the original-path pattern and its conflict-copies glob to `.stignore`. - /// 2. Remove every existing sync-conflict copy of the original file from disk. - /// 3. Trigger a folder rescan so Syncthing's in-memory index reflects the changes. - /// 4. Refresh the iOS-side conflict cache so the resolved conflict disappears. - /// Returns: - /// - `error`: a user-facing error if ANY step failed (`.stignore` write, - /// conflict-copy cleanup, or rescan). The `.stignore` write may have - /// succeeded even when a later step reported an error — check - /// `removedConflicts` to see how many copies were actually deleted - /// before the failure. - /// - `removedConflicts`: the number of on-disk conflict-copy files that were deleted. + /// Retained facade for the former conflict-originated Always Skip flow. + /// Recovery is read-only in 2.0.2, so this returns before reading or writing + /// `.stignore`, removing conflict copies, requesting a scan, or refreshing + /// state. Normal explicit Sync Filters remain available separately. @discardableResult func skipFileAndCleanupConflicts(folderID: String, originalPath: String) -> (error: SyncUserError?, removedConflicts: Int) { - let glob = Self.conflictGlob(forOriginalPath: originalPath) - - guard var current = readIgnorePatternsOrNil(folderID: folderID) else { - return (unreadableFiltersError(), 0) - } - if !current.contains(originalPath) { - current.append(originalPath) - } - if !current.contains(glob) { - current.append(glob) - } - if let err = setIgnorePatterns(folderID: folderID, patterns: current) { - return (err, 0) - } - - let cleanup = SyncBridgeService.removeConflictFilesForOriginal( - folderID: folderID, - originalPath: originalPath + ( + SyncUserError.from(rawMessage: "vaultsync-conflict-recovery-unavailable"), + 0 ) - if let cleanupError = cleanup.error { - // .stignore write succeeded but on-disk cleanup didn't. - // Surface the failure so the user knows the leftover copies - // haven't been removed and the home-screen Sync Issues entry - // may still flag the file. - refreshConflicts() - return (SyncUserError.from(rawMessage: cleanupError), cleanup.removed) - } - - if let rescanError = SyncBridgeService.rescanFolder(folderID: folderID) { - refreshConflicts() - return (SyncUserError.from(rawMessage: rescanError), cleanup.removed) - } - refreshConflicts() - - return (nil, cleanup.removed) } // MARK: - Test hooks @@ -2394,6 +2701,11 @@ final class SyncthingManager { func _testSetConflictFiles(_ newConflicts: [String: [ConflictInfo]]) { conflictFiles = newConflicts + conflictInspectionUnavailableFolderIDs = [] + } + + func _testSetConflictInspectionUnavailableFolderIDs(_ folderIDs: Set) { + conflictInspectionUnavailableFolderIDs = folderIDs } func _testSetFolderStatuses(_ newStatuses: [String: FolderStatusInfo]) { @@ -2404,6 +2716,23 @@ final class SyncthingManager { isRunning = running } + func _testMakeSyncEventItem( + id: Int = 1, + type: String, + time: String = "2026-08-28T12:00:00Z", + data: [String: String], + folderNamesByID: [String: String] = [:], + deviceNamesByID: [String: String] = [:], + folderSafetyStates: [String: ConflictSafetyPolicy.State] = [:] + ) -> SyncEventItem? { + makeSyncEventItem( + from: BridgeEventInfo(id: id, type: type, time: time, relevant: true, data: data), + folderNamesByID: folderNamesByID, + deviceNamesByID: deviceNamesByID, + folderSafetyStates: folderSafetyStates + ) + } + func _testUpdateWidgetSyncMetrics( previousStatuses: [String: FolderStatusInfo], newStatuses: [String: FolderStatusInfo] diff --git a/ios/VaultSync/ViewModels/ObsidianReconnectFlow.swift b/ios/VaultSync/ViewModels/ObsidianReconnectFlow.swift index eecf210..5bbd251 100644 --- a/ios/VaultSync/ViewModels/ObsidianReconnectFlow.swift +++ b/ios/VaultSync/ViewModels/ObsidianReconnectFlow.swift @@ -1,33 +1,20 @@ import Foundation -/// Sequencing core of the "reconnect the Obsidian folder" picker flow -/// (issue #53). Reconnecting produces no `pendingFolders` change event, so -/// the standing `onChange` trigger stays silent and existing pending shares -/// sat untouched until an unrelated change event — this flow runs the accept -/// pass explicitly after a successful grant. -/// -/// The pass runs only AFTER the path reconcile has finished: an accept pass -/// running concurrently would compute its occupied-path set from the -/// pre-reconcile folder list — stale exactly when the user is repairing a -/// container move, which is when this flow typically runs. -/// -/// `reconcile` cannot fail by type (the manager's reconcile task never -/// throws). If it never returns, the retry pass is deliberately NOT fired by -/// a timeout — a timed retry would reintroduce the stale-occupied-set race -/// this ordering exists to prevent; the standing `pendingFolders` change -/// trigger remains in place and covers any later change event. +/// Sequencing core of the "reconnect the Obsidian folder" picker flow. +/// A successful access grant is published immediately, then existing folder +/// paths settle before the flow returns. Pending shares are deliberately not +/// touched: their 2.0.2 surface is inspection-only (#150). /// /// All effects are injected so the sequencing is unit-testable without /// SwiftUI, the filesystem, or the bridge. enum ObsidianReconnectFlow { /// Runs the reconnect sequence. Returns the `grantAccess` error (the - /// sequence aborts, nothing else runs), or nil once the retry pass ran. + /// sequence aborts, nothing else runs), or nil once reconciliation ends. @MainActor static func run( grantAccess: @MainActor () -> String?, onGrantSucceeded: @MainActor () -> Void, - reconcile: @MainActor () async -> Void, - retryPendingShares: @MainActor () -> Void + reconcile: @MainActor () async -> Void ) async -> String? { if let error = grantAccess() { return error @@ -36,7 +23,6 @@ enum ObsidianReconnectFlow { // wait for the reconcile's engine round-trips. onGrantSucceeded() await reconcile() - retryPendingShares() return nil } } diff --git a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift index 8d0641f..07e702e 100644 --- a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift +++ b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift @@ -155,10 +155,15 @@ final class SetupChecklistViewModel { private var firstShareItem: ChecklistItem { if !syncthingManager.folders.isEmpty { + let hasSendOnly = syncthingManager.folders.contains { + ConflictSafetyPolicy.runtimeState(forFolderType: $0.type) == .clear + } return ChecklistItem( requirement: .firstShareDetectedOrAccepted, - title: L10n.tr("Vault syncing"), - description: L10n.tr("At least one Obsidian vault is active in VaultSync."), + title: L10n.tr("Vault configured"), + description: hasSendOnly + ? L10n.tr("At least one Send Only vault can continue uploading local changes.") + : L10n.tr("Existing receive-capable vaults are available for review only in this version."), remediation: "", isOptional: false, isComplete: true @@ -168,24 +173,20 @@ final class SetupChecklistViewModel { if !syncthingManager.actionablePendingFolders.isEmpty { return ChecklistItem( requirement: .firstShareDetectedOrAccepted, - title: L10n.tr("Vault syncing"), - description: L10n.tr("A vault offer is waiting to be accepted."), - remediation: L10n.tr("A vault offer is waiting. Accept it from Pending Shares on the home screen."), + title: L10n.tr("Vault setup"), + description: L10n.tr("A vault offer is available for inspection."), + remediation: L10n.tr("Open Pending Shares to inspect the offer details. This version cannot accept it."), isOptional: false, isComplete: false ) } - // An ignored offer cannot be revived from the desktop: sharing again - // produces no new offer while the old one sits ignored, so the - // "share again from your computer" advice below would be a dead end - // (#95). Point at the in-app restore instead. if !syncthingManager.ignoredPendingFolders.isEmpty { return ChecklistItem( requirement: .firstShareDetectedOrAccepted, - title: L10n.tr("Vault syncing"), - description: L10n.tr("A vault offer was ignored on this iPhone, so it is not accepted automatically."), - remediation: L10n.tr("Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer."), + title: L10n.tr("Vault setup"), + description: L10n.tr("An ignored vault offer remains stored on this iPhone."), + remediation: L10n.tr("Open Pending Shares to inspect its details. No action is available in this version."), isOptional: false, isComplete: false ) @@ -194,9 +195,9 @@ final class SetupChecklistViewModel { if syncthingManager.hasSeenPendingFolderOffer { return ChecklistItem( requirement: .firstShareDetectedOrAccepted, - title: L10n.tr("Vault syncing"), - description: L10n.tr("A vault offer was seen earlier, but no vault is syncing right now."), - remediation: L10n.tr("If syncing has not started, share your Obsidian vault again from Syncthing on your computer."), + title: L10n.tr("Vault setup"), + description: L10n.tr("A vault offer was seen earlier, but no vault is configured right now."), + remediation: L10n.tr("New share acceptance is unavailable in this version."), isOptional: false, isComplete: false ) @@ -204,9 +205,9 @@ final class SetupChecklistViewModel { return ChecklistItem( requirement: .firstShareDetectedOrAccepted, - title: L10n.tr("Vault syncing"), + title: L10n.tr("Vault setup"), description: L10n.tr("No Obsidian vault is active in VaultSync yet."), - remediation: L10n.tr("Share your Obsidian vault from Syncthing on your computer."), + remediation: L10n.tr("New shared vault offers can be inspected, but not accepted in this version."), isOptional: false, isComplete: false ) @@ -237,8 +238,8 @@ final class SetupChecklistViewModel { return ChecklistItem( requirement: .relayConfigured, title: L10n.tr("Cloud Relay"), - description: L10n.tr("Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync."), - remediation: L10n.tr("Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen."), + description: L10n.tr("Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies."), + remediation: L10n.tr("Enable Cloud Relay on the Relay tab to wake VaultSync for background checks."), isOptional: true, isComplete: false, action: .openRelayTab @@ -257,7 +258,7 @@ final class SetupChecklistViewModel { return ChecklistItem( requirement: .relayConfigured, title: L10n.tr("Cloud Relay active"), - description: L10n.tr("Wake-ups are being delivered — incoming changes sync the moment they happen."), + description: L10n.tr("Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes."), remediation: "", isOptional: true, isComplete: true diff --git a/ios/VaultSync/ViewModels/ShareAcceptCoordinator.swift b/ios/VaultSync/ViewModels/ShareAcceptCoordinator.swift index 50a1942..a8ca805 100644 --- a/ios/VaultSync/ViewModels/ShareAcceptCoordinator.swift +++ b/ios/VaultSync/ViewModels/ShareAcceptCoordinator.swift @@ -1,23 +1,11 @@ import Foundation import Observation -/// The pending-share accept pass, extracted from ContentView so it is no -/// longer tied to one mount point (issue #92): ContentView owned the only -/// accept triggers, so a share offered while onboarding was on screen sat -/// invisible until the user left onboarding — while setup step 3 promised -/// automatic acceptance. OnboardingView and ContentView now drive this one -/// coordinator with the IDENTICAL gates: -/// -/// - settled paths (#56, decision 008), -/// - Obsidian access (`vaultAccessible`), -/// - the auto-accept eligibility list (ignored and user-removed shares stay -/// manual — doctrine 002 / #52), -/// - the merge-consent handshake (#54, decision 007): the automatic pass only -/// parks a needs-merge share; the dialog is a manual-accept affair. -/// -/// All effects are injected (`Environment`) so every gate and outcome is -/// unit-testable without SwiftUI, the filesystem, or the bridge — -/// `ShareAcceptCoordinatorTests` (#92); house pattern: PathCollisionGuard. +/// Retained injectable acceptance core for regression coverage of the former +/// flow. In 2.0.2 it has no live environment factory and no shipping view or +/// app construction: pending shares are inspection-only (#150). A future +/// caller requires a separately approved recovery doctrine and explicit live +/// wiring instead of bypassing that policy here. @MainActor @Observable final class ShareAcceptCoordinator { @@ -40,10 +28,11 @@ final class ShareAcceptCoordinator { case manual } - /// Injected effects — `live` binds them to the managers. + /// Injected effects used only by focused regression tests in 2.0.2. struct Environment { var settled: @MainActor () -> Bool var vaultAccessible: @MainActor () -> Bool + var receiveSafetyState: @MainActor () -> ConflictSafetyPolicy.State = { .unknown } var pendingFolders: @MainActor () -> [SyncthingManager.PendingFolderInfo] var autoAcceptEligible: @MainActor () -> [SyncthingManager.PendingFolderInfo] var accept: @MainActor (_ folder: SyncthingManager.PendingFolderInfo, _ mergeConfirmed: Bool) -> PendingShareAcceptOutcome @@ -57,36 +46,6 @@ final class ShareAcceptCoordinator { var markRefusalAlertPresented: @MainActor (_ folderID: String, _ reason: String) -> Void = { _, _ in } var clearRefusalAlertRecord: @MainActor (_ folderID: String) -> Void = { _ in } - static func live( - syncthingManager: SyncthingManager, - vaultManager: VaultManager - ) -> Environment { - Environment( - settled: { syncthingManager.pathSettlement.settled }, - vaultAccessible: { vaultManager.isAccessible }, - pendingFolders: { syncthingManager.pendingFolders }, - autoAcceptEligible: { syncthingManager.autoAcceptEligiblePendingFolders }, - accept: { folder, mergeConfirmed in - vaultManager.acceptPendingShare( - folder: folder, - syncthingManager: syncthingManager, - mergeConfirmed: mergeConfirmed - ) - }, - acceptIntoTarget: { folder, targetName in - vaultManager.acceptPendingShare( - folder: folder, - intoTargetNamed: targetName, - syncthingManager: syncthingManager - ) - }, - unignorePendingFolder: { syncthingManager.unignorePendingFolder(id: $0) }, - ignorePendingFolder: { syncthingManager.ignorePendingFolder(id: $0) }, - shouldPresentRefusalAlert: { ShareRefusalAlertStore.shouldPresentAlert(folderID: $0, reason: $1) }, - markRefusalAlertPresented: { ShareRefusalAlertStore.markPresented(folderID: $0, reason: $1) }, - clearRefusalAlertRecord: { ShareRefusalAlertStore.clear(folderID: $0) } - ) - } } // MARK: - Published state @@ -116,6 +75,13 @@ final class ShareAcceptCoordinator { pendingShareFailures = pendingShareFailures.filter { pendingIDs.contains($0.key) } pendingShareInFlight = pendingShareInFlight.intersection(pendingIDs) + if let safetyError = receiveSafetyError() { + for folder in environment.autoAcceptEligible() { + pendingShareFailures[folder.id] = safetyError + } + return + } + // Accept decisions only run on settled paths (#56, decision 008): a // pass during a pending path reconcile would judge overlap against // pre-reconcile folder paths — stale exactly after a container move. @@ -140,6 +106,11 @@ final class ShareAcceptCoordinator { source: AcceptSource, mergeConfirmed: Bool = false ) { + if let safetyError = receiveSafetyError() { + pendingShareFailures[folder.id] = safetyError + return + } + // Accept decisions only run on settled paths (#56, decision 008). The // automatic pass is already held in runAutomaticPass and re-fires on // settle; this guard also covers the manual paths (retry, merge @@ -222,6 +193,11 @@ final class ShareAcceptCoordinator { folder: SyncthingManager.PendingFolderInfo, intoTargetNamed targetName: String ) -> String? { + if let safetyError = receiveSafetyError() { + pendingShareFailures[folder.id] = safetyError + return safetyError.userVisibleDescription + } + // Accept decisions only run on settled paths (#56, decision 008) — // the picker's empty-target and overlap validation (#52) reads the // same occupied-path set the reconcile is still rewriting. @@ -247,12 +223,21 @@ final class ShareAcceptCoordinator { /// resolved path) is re-validated at confirm time, so a state change while /// the dialog was open cannot smuggle the accept somewhere unsafe. func confirmMergeAccept(_ request: MergeConfirmationRequest) { + if let safetyError = receiveSafetyError() { + pendingMergeConfirmation = nil + pendingShareFailures[request.folder.id] = safetyError + return + } pendingMergeConfirmation = nil pendingShareFailures.removeValue(forKey: request.folder.id) accept(request.folder, source: .manual, mergeConfirmed: true) } func retry(_ folder: SyncthingManager.PendingFolderInfo) { + if let safetyError = receiveSafetyError() { + pendingShareFailures[folder.id] = safetyError + return + } pendingShareFailures.removeValue(forKey: folder.id) accept(folder, source: .manual) } @@ -268,4 +253,10 @@ final class ShareAcceptCoordinator { func clearRecordedFailures() { pendingShareFailures.removeAll() } + + private func receiveSafetyError() -> SyncUserError? { + let state = environment.receiveSafetyState() + guard state != .clear else { return nil } + return SyncUserError.conflictSafetyError(for: state) + } } diff --git a/ios/VaultSync/ViewModels/SyncHeaderModel.swift b/ios/VaultSync/ViewModels/SyncHeaderModel.swift index 86a7aa6..ae1d5ab 100644 --- a/ios/VaultSync/ViewModels/SyncHeaderModel.swift +++ b/ios/VaultSync/ViewModels/SyncHeaderModel.swift @@ -8,8 +8,8 @@ import Foundation /// (parked shares, disconnected required peers, conflicts, stale sync) never /// reached the header and a green "All Synced" coexisted with visible issue /// rows. The header now derives from the max severity of that same issue -/// list, and "Ready" is claimed only when the app is genuinely armed to -/// accept a share (Obsidian folder accessible and a vault exists). +/// list. A folder-less installation remains neutral: new share acceptance is +/// unavailable in 2.0.2, even when a local vault is detected (#150). /// /// Pure and value-typed so the precedence cascade is exhaustively /// unit-testable without a manager or the bridge. @@ -71,9 +71,7 @@ enum SyncHeaderModel { return State(status: .synced, titleKey: "All Synced") } if inputs.hasDetectedVaults { - // Genuinely armed: the auto-accept pass can act the moment a - // share arrives — this is the only folder-less "Ready". - return State(status: .synced, titleKey: "Ready") + return State(status: .starting, titleKey: "No Vaults Syncing") } // Accessible but no vault exists yet — waiting on the user to create // one in Obsidian. A calm waiting state, never a green check. @@ -81,7 +79,7 @@ enum SyncHeaderModel { } /// Widget-snapshot tier (#73). The widget carries no vault-setup surface, - /// so the vault tiers are pinned "armed" and the cascade reduces to the + /// so vault access and detection are pinned true and the cascade reduces to the /// engine / issue / transfer tiers — but it IS the same cascade above /// (decision 012), so an issue kind that reaches the header can never /// miss the widget again. Before this, the widget only knew diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index 01a5deb..7076b98 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -5,54 +5,24 @@ struct ConflictDiffView: View { let conflict: SyncthingManager.ConflictInfo let syncthingManager: SyncthingManager - @State private var originalContent = "" - @State private var conflictContent = "" + @State private var originalInspection: SyncBridgeService.FileInspectionResult? + @State private var conflictInspection: SyncBridgeService.FileInspectionResult? @State private var isLoading = true - @State private var loadError: String? - @State private var alertMessage: String? - @State private var showAlert = false @State private var showLineDiff = false - - // Action confirmation flow - @State private var actionToConfirm: ResolveAction? - @State private var showConfirmAlert = false - - // Result summary flow - @State private var resultSummaryMessage = "" - @State private var showResultSummary = false - - // Always-skip flow - @State private var showSkipConfirmation = false - @State private var skipRemovedCount: Int = 0 - @State private var skipErrorMessage: String? - @State private var showSkipError = false - - @Environment(\.dismiss) private var dismiss - - enum ResolveAction { - case keepThis - case keepOther - case keepBoth - } var body: some View { Group { if isLoading { - ProgressView("Loading files…") - } else if let loadError { - ContentUnavailableView( - "Cannot Load Files", - systemImage: "exclamationmark.triangle", - description: Text(loadError) - ) + ProgressView(L10n.tr("Loading files…")) } else { ScrollView { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: VaultSpacing.l) { + inspectionNotice + + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(conflict.originalPath) .font(.headline) - HStack(spacing: 8) { - Label(conflict.deviceShortID, systemImage: "laptopcomputer") + HStack(spacing: VaultSpacing.s) { Label(conflict.formattedConflictDate, systemImage: "clock") } .font(.caption) @@ -60,12 +30,14 @@ struct ConflictDiffView: View { .accessibilityElement(children: .combine) } .padding(.horizontal) - + Divider() - - Toggle("Show Line-by-Line Diff", isOn: $showLineDiff) - .padding(.horizontal) - .padding(.bottom, 4) + + if comparableContents != nil { + Toggle(L10n.tr("Show Line-by-Line Diff"), isOn: $showLineDiff) + .padding(.horizontal) + .padding(.bottom, VaultSpacing.xxs) + } comparisonContent } @@ -73,212 +45,93 @@ struct ConflictDiffView: View { } } } - .navigationTitle("Resolve Conflict") + .navigationTitle(L10n.tr("Conflict Details")) .navigationBarTitleDisplayMode(.inline) - #if DEBUG - .onAppear { - // LAB: present the resolve consent immediately for the UI-audit - // fixture run (#64); reachable only via launch argument. - if UIAuditFixture.active == UIAuditFixture.conflictResolveConsent { - actionToConfirm = .keepThis - showConfirmAlert = true - } - } - #endif - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - skipThisFile() - } label: { - Label(L10n.tr("Always skip on this iPhone"), - systemImage: "line.3.horizontal.decrease.circle") - } - } label: { - Image(systemName: "ellipsis.circle") - .accessibilityLabel(L10n.tr("More actions")) - } - } - } - .safeAreaInset(edge: .bottom) { - if !isLoading && loadError == nil { - resolutionBar - } - } - .alert("Error", isPresented: $showAlert) { - Button("OK") { } - } message: { - Text(alertMessage ?? "") - } - // Same iOS-26 rendering defect as the consent dialogs: a - // .confirmationDialog hides its cancel-role button there, and one of - // these actions discards a version of a note — .alert keeps Cancel - // visible on every OS version (#64, decision 011). - .alert( - "Resolve Conflict", - isPresented: $showConfirmAlert, - presenting: actionToConfirm - ) { action in - Button(confirmButtonTitle(for: action), role: action == .keepBoth ? nil : .destructive) { - executeAction(action) - } - Button("Cancel", role: .cancel) { } - } message: { action in - Text(confirmMessage(for: action)) - } - .alert("Conflict Resolved", isPresented: $showResultSummary) { - Button("Done") { - dismiss() - } - } message: { - Text(resultSummaryMessage) - } - .alert(L10n.tr("Skipping enabled"), isPresented: $showSkipConfirmation) { - Button("OK") { - showSkipConfirmation = false - dismiss() - } - } message: { - let base = L10n.fmt( - "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters.", - conflict.originalPath - ) - if skipRemovedCount == 1 { - Text(base + "\n\n" + L10n.tr("1 existing conflict copy was removed.")) - } else if skipRemovedCount > 1 { - Text(base + "\n\n" + L10n.fmt("%d existing conflict copies were removed.", skipRemovedCount)) - } else { - Text(base) - } - } - .alert(L10n.tr("Could not add filter"), isPresented: $showSkipError) { - Button("OK") { showSkipError = false } - } message: { - Text(skipErrorMessage ?? "") - } .task { await loadContent() } } - /// The bottom resolution bar: full-width, ≥44pt buttons (replacing the tiny - /// caption2 tab-bar-style icons). Every action routes through confirmAction so - /// all three confirm before mutating files — including Keep Both, which used - /// to mutate with no confirmation. - private var resolutionBar: some View { - VStack(spacing: VaultSpacing.s) { - Button { - confirmAction(.keepThis) - } label: { - Label(L10n.tr("Keep This Device's Version"), systemImage: "iphone") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .accessibilityHint(L10n.tr("Discards the version from the other device.")) - - HStack(spacing: VaultSpacing.s) { - Button { - confirmAction(.keepBoth) - } label: { - Text(L10n.tr("Keep Both")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityHint(L10n.tr("Keeps your local file and renames the other device's file.")) - - Button(role: .destructive) { - confirmAction(.keepOther) - } label: { - Text(L10n.tr("Keep Other")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityHint(L10n.tr("Overwrites your local file with the version from the other device.")) - } + private var inspectionNotice: some View { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + Label(L10n.tr("Conflict Recovery Unavailable"), systemImage: "lock.fill") + .font(.headline) + .foregroundStyle(Color.statusAttention) + Text(L10n.tr("Conflict recovery actions are not available in this version.")) + .font(.subheadline) + Text(L10n.tr("Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version.")) + .font(.caption) + .foregroundStyle(.secondary) } - .controlSize(.large) - .tint(.vaultAccent) - .padding(VaultSpacing.l) - .background(.bar) + .padding(.horizontal) + .accessibilityElement(children: .combine) } /// The body of the comparison — line-by-line diff (with a colour/sign legend) - /// or the two side-by-side file panes. Extracted from `body` to keep each - /// view expression small enough for the Swift type-checker. + /// or the two side-by-side file panes. @ViewBuilder private var comparisonContent: some View { - if showLineDiff { - VStack(alignment: .leading, spacing: 4) { - Text("Differences") + if showLineDiff, let comparableContents { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { + Text(L10n.tr("Differences")) .font(.subheadline.bold()) .padding(.horizontal) diffLegend - LineDiffView(original: originalContent, conflict: conflictContent) + LineDiffView( + original: comparableContents.original, + conflict: comparableContents.conflict + ) } } else { fileSection( - title: L10n.tr("This Device"), - icon: "iphone", - content: originalContent + title: L10n.tr("Current File"), + icon: "doc.text", + inspection: originalInspection ?? .unavailable ) fileSection( - title: L10n.fmt("Other Device (%@)", conflict.deviceShortID), - icon: "laptopcomputer", - content: conflictContent + title: L10n.tr("Conflict Copy"), + icon: "doc.on.doc", + inspection: conflictInspection ?? .unavailable ) } } - /// Legend so the +/green and -/red mapping is explicit (colour is never the - /// only signal — the +/- symbols carry the same meaning for colourblind and - /// VoiceOver users). + private var comparableContents: (original: String, conflict: String)? { + guard case let .content(original)? = originalInspection, + case let .content(conflict)? = conflictInspection else { + return nil + } + return (original, conflict) + } + + /// Colour is never the only signal: the plus/minus symbols carry the same + /// meaning for colourblind users and VoiceOver. private var diffLegend: some View { - HStack(spacing: 12) { - Label(L10n.tr("Other Device"), systemImage: "plus") + HStack(spacing: VaultSpacing.m) { + Label(L10n.tr("Conflict Copy"), systemImage: "plus") .foregroundStyle(Color.statusSuccess) - Label(L10n.tr("This Device"), systemImage: "minus") + Label(L10n.tr("Current File"), systemImage: "minus") .foregroundStyle(Color.statusError) } .font(.caption2) .padding(.horizontal) .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.tr("Added lines come from the other device; removed lines are your version on this device.")) + .accessibilityLabel(L10n.tr("Added lines are from the conflict copy; removed lines are from the current file.")) } - private func skipThisFile() { - // Wrap the call in a Task so the button handler returns immediately - // and SwiftUI can dispatch any UI updates (alert presentation, view - // dismiss) cleanly. The work itself still runs on the main actor — - // skipFileAndCleanupConflicts is @MainActor-isolated because it - // reads/writes SyncthingManager state — so this does not yet move - // the file I/O off the main thread. A fuller move to a background - // executor would require splitting the bridge cleanup, rescan, and - // refresh paths into nonisolated entry points, which is a separate - // refactor. - Task { @MainActor in - let (err, removed) = syncthingManager.skipFileAndCleanupConflicts( - folderID: folderID, - originalPath: conflict.originalPath - ) - if let err { - skipErrorMessage = err.message - showSkipError = true - return - } - skipRemovedCount = removed - showSkipConfirmation = true - } - } - - private func fileSection(title: String, icon: String, content: String) -> some View { - VStack(alignment: .leading, spacing: 4) { + private func fileSection( + title: String, + icon: String, + inspection: SyncBridgeService.FileInspectionResult + ) -> some View { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Label(title, systemImage: icon) .font(.subheadline.bold()) .padding(.horizontal) ScrollView(.horizontal, showsIndicators: false) { - Text(content.isEmpty ? L10n.tr("(empty or unreadable)") : content) + Text(fileInspectionText(inspection)) .font(.vaultMono(.caption)) .padding(VaultSpacing.m) .frame(maxWidth: .infinity, alignment: .leading) @@ -289,114 +142,27 @@ struct ConflictDiffView: View { } } + private func fileInspectionText(_ inspection: SyncBridgeService.FileInspectionResult) -> String { + switch inspection { + case let .content(content): + return content.isEmpty ? L10n.tr("(empty)") : content + case .unavailable: + return L10n.tr("This copy is unavailable for inspection.") + } + } + private func loadContent() async { let capturedFolderID = folderID let capturedConflict = conflict - let (orig, conf, err): (String, String, String?) = await Task.detached { + let (orig, conf) = await Task.detached { let o = SyncBridgeService.readFileContent(folderID: capturedFolderID, relPath: capturedConflict.originalPath) let c = SyncBridgeService.readFileContent(folderID: capturedFolderID, relPath: capturedConflict.conflictPath) - if let oErr = o.error, let cErr = c.error { - let oUser = SyncUserError.from(rawMessage: oErr, fallbackTitle: L10n.tr("File Read Failed")) - let cUser = SyncUserError.from(rawMessage: cErr, fallbackTitle: L10n.tr("File Read Failed")) - return ("", "", L10n.fmt("Could not read files.\n\n%@\n%@", oUser.message, cUser.message)) - } - if let oErr = o.error { - let user = SyncUserError.from(rawMessage: oErr, fallbackTitle: L10n.tr("File Read Failed")) - return ("", c.content ?? "", L10n.fmt("Could not read original file.\n\n%@", user.userVisibleDescription)) - } - if let cErr = c.error { - let user = SyncUserError.from(rawMessage: cErr, fallbackTitle: L10n.tr("File Read Failed")) - return (o.content ?? "", "", L10n.fmt("Could not read conflict file.\n\n%@", user.userVisibleDescription)) - } - return (o.content ?? "", c.content ?? "", nil as String?) + return (o, c) }.value - originalContent = orig - conflictContent = conf - if let err { - loadError = err - } + originalInspection = orig + conflictInspection = conf isLoading = false } - - private func confirmAction(_ action: ResolveAction) { - actionToConfirm = action - showConfirmAlert = true - } - - private func confirmMessage(for action: ResolveAction) -> String { - switch action { - case .keepThis: - return L10n.tr("This will permanently discard the version from the other device.") - case .keepOther: - return L10n.tr("This will permanently discard your local version.") - case .keepBoth: - return L10n.tr("Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded.") - } - } - - private func confirmButtonTitle(for action: ResolveAction) -> String { - switch action { - case .keepThis: return L10n.tr("Keep This Device's Version") - case .keepOther: return L10n.tr("Keep Other Device's Version") - case .keepBoth: return L10n.tr("Keep Both") - } - } - - private func executeAction(_ action: ResolveAction) { - switch action { - case .keepThis: - resolve(keepConflict: false) - case .keepOther: - resolve(keepConflict: true) - case .keepBoth: - keepBoth() - } - } - - private func resolve(keepConflict: Bool) { - if let err = syncthingManager.resolveConflict( - folderID: folderID, - conflictFileName: conflict.conflictPath, - keepConflict: keepConflict - ) { - alertMessage = SyncUserError.from( - rawMessage: err, - fallbackTitle: L10n.tr("Conflict Resolution Failed") - ).userVisibleDescription - showAlert = true - } else { - let filename = (conflict.originalPath as NSString).lastPathComponent - if keepConflict { - resultSummaryMessage = L10n.fmt("The file '%@' was overwritten with the version from the other device.", filename) - } else { - resultSummaryMessage = L10n.fmt("The file '%@' was kept as your local version. The other device's version was discarded.", filename) - } - showResultSummary = true - } - } - - private func keepBoth() { - let (err, newPath) = syncthingManager.keepBothConflict( - folderID: folderID, - conflict: conflict - ) - if let err { - alertMessage = SyncUserError.from( - rawMessage: err, - fallbackTitle: L10n.tr("Conflict Resolution Failed") - ).userVisibleDescription - showAlert = true - } else { - let filename = (conflict.originalPath as NSString).lastPathComponent - let renamedFilename = newPath != nil ? (newPath! as NSString).lastPathComponent : L10n.tr("a new name") - resultSummaryMessage = L10n.fmt( - "Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'.", - filename, - renamedFilename - ) - showResultSummary = true - } - } } diff --git a/ios/VaultSync/Views/ConflictListView.swift b/ios/VaultSync/Views/ConflictListView.swift index 932243d..f9eadea 100644 --- a/ios/VaultSync/Views/ConflictListView.swift +++ b/ios/VaultSync/Views/ConflictListView.swift @@ -8,31 +8,63 @@ struct ConflictListView: View { var pathPrefix: String? = nil let syncthingManager: SyncthingManager - /// Read live from the manager so a conflict resolved in the detail view - /// disappears immediately. The view previously held a by-value snapshot - /// captured at push time, which left resolved files as tappable dead rows. + /// Read live from the manager so an engine refresh is reflected without + /// retaining a stale by-value snapshot captured at navigation time. private var conflicts: [SyncthingManager.ConflictInfo] { let all = syncthingManager.conflictFiles[folderID] ?? [] guard let prefix = pathPrefix else { return all } return all.filter { $0.belongs(toVault: prefix) } } + private var inspectionUnavailable: Bool { + syncthingManager.conflictInspectionUnavailableFolderIDs.contains(folderID) + } + var body: some View { List { Section { VStack(alignment: .leading, spacing: VaultSpacing.s) { - Text("What is a conflict?") + Text(L10n.tr("What is a conflict?")) .font(.headline) - Text("A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss.") + Text(L10n.tr("A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available.")) .font(.subheadline) .foregroundStyle(.secondary) } .padding(.vertical, VaultSpacing.xs) } - + Section { - if conflicts.isEmpty { - Label("All conflicts resolved", systemImage: "checkmark.circle") + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + Label(L10n.tr("Conflict Recovery Unavailable"), systemImage: "lock.fill") + .foregroundStyle(Color.statusAttention) + .font(.headline) + Text(L10n.tr("Conflict recovery actions are not available in this version.")) + .font(.subheadline) + Text(L10n.tr("Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version.")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, VaultSpacing.xs) + } + + Section { + if inspectionUnavailable { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + Label( + L10n.tr("Conflict inspection is unavailable."), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.headline) + .foregroundStyle(Color.statusAttention) + Text(L10n.tr("VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review.")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, VaultSpacing.xs) + } + + if conflicts.isEmpty, !inspectionUnavailable { + Label(L10n.tr("No conflicts found"), systemImage: "doc.text.magnifyingglass") .foregroundStyle(.secondary) } else { ForEach(conflicts) { conflict in @@ -48,7 +80,7 @@ struct ConflictListView: View { .font(.body) HStack(spacing: VaultSpacing.s) { Label(conflict.formattedConflictDate, systemImage: "clock") - Label(conflict.deviceShortID, systemImage: "laptopcomputer") + Label(L10n.tr("Conflict Copy"), systemImage: "doc.on.doc") } .font(.caption) .foregroundStyle(.secondary) @@ -59,10 +91,10 @@ struct ConflictListView: View { } } } header: { - Text("Conflicted Files") + Text(L10n.tr("Conflicted Files")) } } - .navigationTitle("Conflicts") + .navigationTitle(L10n.tr("Conflicts")) .navigationBarTitleDisplayMode(.inline) } diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 189659b..84892fc 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -5,7 +5,6 @@ struct ContentView: View { var syncthingManager: SyncthingManager var vaultManager: VaultManager var subscriptionManager: SubscriptionManager - var shareAccept: ShareAcceptCoordinator @State private var showAddDevice = false @State private var showSettings = false @State private var showSetupChecklist = false @@ -21,7 +20,6 @@ struct ContentView: View { /// it is not presented under the "Error" title. @State private var infoMessage: String? @State private var showInfoAlert = false - @State private var shareTargetPickerFolder: SyncthingManager.PendingFolderInfo? @State private var pendingFilterSheetFolder: SyncthingManager.FolderInfo? @State private var vaultPendingRemoval: VaultRemovalTarget? @State private var showRelayUpsellCard = false @@ -135,11 +133,6 @@ struct ContentView: View { if let err = await ObsidianReconnectFlow.run( grantAccess: { vaultManager.grantAccess(url: url) }, onGrantSucceeded: { - // A share that had no safe location under the old - // root (e.g. the root was itself a vault, #45 - // follow-up) may succeed under the new one — clear - // the failures so the retry pass attempts it. - shareAccept.clearRecordedFailures() if let advisory = vaultManager.selectionAdvisory { infoMessage = advisory showInfoAlert = true @@ -154,13 +147,6 @@ struct ContentView: View { await syncthingManager.reconcileFolderPaths( obsidianRoot: vaultManager.obsidianBasePath ).value - }, - retryPendingShares: { - // Reconnecting produces no pendingFolders change - // event, so the standing onChange trigger stays - // silent — run the accept pass explicitly, on - // settled paths (#53). - shareAccept.runAutomaticPass() } ) { alertMessage = mappedError(err, fallbackTitle: L10n.tr("Obsidian Folder Connection Failed")).userVisibleDescription @@ -186,52 +172,6 @@ struct ContentView: View { } message: { target in Text(L10n.fmt("“%@” will stop syncing on this iPhone. Files already on your other devices are not deleted.", target.label)) } - .alert( - L10n.tr("Sync into a folder that already contains files?"), - isPresented: mergeConfirmationBinding, - presenting: shareAccept.pendingMergeConfirmation - ) { request in - Button(L10n.tr("Merge and Sync"), role: .destructive) { - shareAccept.confirmMergeAccept(request) - } - Button(L10n.tr("Cancel"), role: .cancel) { shareAccept.pendingMergeConfirmation = nil } - } message: { request in - Text(L10n.fmt( - "The folder \"%@\" already contains files. If you accept, those files and the contents of the shared vault \"%@\" will be combined and synced to the other devices sharing this vault. Accept only if this folder holds this vault's own earlier notes — for example after removing the vault and accepting its share again. If it is a different vault or unrelated files, cancel and use \"Choose Vault…\" to pick a different location.", - request.targetName, - request.folder.label.isEmpty ? request.folder.id : request.folder.label - )) - } - .sheet(item: $shareTargetPickerFolder) { folder in - ShareTargetPickerView( - shareLabel: folder.label.isEmpty ? folder.id : folder.label, - defaultName: VaultManager.sanitizeDirectoryName(folder.label.isEmpty ? folder.id : folder.label), - eligibleVaults: vaultManager.eligibleShareTargets(syncthingManager: syncthingManager), - onConfirm: { targetName in - shareAccept.acceptManually(folder: folder, intoTargetNamed: targetName) - } - ) - } - .onChange(of: syncthingManager.pendingFolders, initial: true) { _, _ in - shareAccept.runAutomaticPass() - } - .onChange(of: syncthingManager.pathSettlement.settled) { _, settled in - // Paths just settled: run the pass that was held during the - // reconcile (#56). pendingFolders itself did not change, so the - // standing trigger above stays silent — the same gap #53 closed - // for the reconnect flow. - if settled { - shareAccept.runAutomaticPass() - } - } - .onChange(of: shareAccept.alertMessage) { _, message in - // The coordinator is host-agnostic (#92): whichever view is - // mounted routes its one-shot messages into its own alert. - guard let message else { return } - shareAccept.alertMessage = nil - alertMessage = message - showAlert = true - } .onChange(of: syncthingManager.lastSyncTime, initial: true) { _, _ in maybePresentRelayUpsell() maybePresentNotificationPrimer() @@ -255,12 +195,6 @@ struct ContentView: View { device: Self.uiAuditFixtureDevice, syncthingManager: syncthingManager ) - case .conflictResolve: - ConflictDiffView( - folderID: "uiaudit-vault", - conflict: Self.uiAuditFixtureConflict, - syncthingManager: syncthingManager - ) } } } @@ -281,8 +215,7 @@ struct ContentView: View { } .refreshable { // Re-detect vaults created in Obsidian since the last scan - // (#95). Read-only: republishes detectedVaults only — the - // accept pass keys on pendingFolders/settlement, never on this. + // (#95). Read-only: republishes detectedVaults only. vaultManager.scanForVaults() await syncthingManager.performForegroundSync() } @@ -463,7 +396,7 @@ struct ContentView: View { private func presentDeviceAddedHintIfNeeded() { guard showDeviceAddedHint else { return } showDeviceAddedHint = false - infoMessage = L10n.tr("Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing.") + infoMessage = L10n.tr("Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version.") showInfoAlert = true } @@ -472,7 +405,6 @@ struct ContentView: View { #if DEBUG private enum UIAuditDetailFixture: String, Identifiable { case deviceRemoval - case conflictResolve var id: String { rawValue } } @@ -483,15 +415,6 @@ struct ContentView: View { /// see UIAuditFixture. Compiled out of release builds. private func applyUIAuditFixture() { switch UIAuditFixture.active { - case UIAuditFixture.mergeConsent: - shareAccept.pendingMergeConfirmation = ShareAcceptCoordinator.MergeConfirmationRequest( - folder: SyncthingManager.PendingFolderInfo( - id: "uiaudit-vault", - label: "Life Notes", - offeredBy: [] - ), - targetName: "Life Notes" - ) case UIAuditFixture.removalConsent: vaultPendingRemoval = VaultRemovalTarget(id: "uiaudit-vault", label: "Life Notes") case UIAuditFixture.markerError: @@ -525,8 +448,6 @@ struct ContentView: View { ]) case UIAuditFixture.deviceRemovalConsent: uiAuditDetailFixture = .deviceRemoval - case UIAuditFixture.conflictResolveConsent: - uiAuditDetailFixture = .conflictResolve default: break } @@ -541,12 +462,6 @@ struct ContentView: View { ) }() - private static let uiAuditFixtureConflict = SyncthingManager.ConflictInfo( - originalPath: "Notes/daily.md", - conflictPath: "Notes/daily.sync-conflict-20260707-101010-UIAUDIT.md", - conflictDate: "2026-07-07T10:10:10Z", - deviceShortID: "UIAUDIT" - ) #endif // MARK: - Dashboard Section @@ -613,7 +528,7 @@ struct ContentView: View { } } else if !syncthingManager.folders.isEmpty, !showRelayUpsellCard { relayNavRow( - title: L10n.tr("Get instant updates"), + title: L10n.tr("Enable background wake-ups"), subtitle: L10n.tr("Turn on Cloud Relay"), status: nil, systemImage: "antenna.radiowaves.left.and.right" @@ -734,10 +649,10 @@ struct ContentView: View { Image(systemName: "antenna.radiowaves.left.and.right") .foregroundStyle(accent) .accessibilityHidden(true) - Text(L10n.tr("Get instant updates")) + Text(L10n.tr("Enable background wake-ups")) .font(.headline) } - Text(L10n.tr("Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed.")) + Text(L10n.tr("Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -773,7 +688,7 @@ struct ContentView: View { Text(L10n.tr("Get notified about conflicts")) .font(.headline) } - Text(L10n.tr("If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep.")) + Text(L10n.tr("If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -898,7 +813,7 @@ struct ContentView: View { if let issue = vaultManager.accessIssue { return joinedErrorMessage(issue.message, issue.remediation) } - return L10n.tr("VaultSync needs one-time access to your Obsidian folder before it can accept shares.") + return L10n.tr("VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status.") } /// Picker guidance + first-install help below the connect button. @@ -940,28 +855,7 @@ struct ContentView: View { Section("Pending Shares") { PendingSharesView( pendingFolders: pendingFolders, - ignoredFolders: ignoredFolders, - failureByFolderID: shareAccept.pendingShareFailures, - inFlightFolderIDs: shareAccept.pendingShareInFlight, - obsidianAccessible: vaultManager.isAccessible, - onAccept: { folder in - shareAccept.accept(folder, source: .manual) - }, - onRetry: { folder in - shareAccept.retry(folder) - }, - onIgnore: { folder in - shareAccept.ignore(folder) - }, - onRestoreIgnored: { folder in - syncthingManager.unignorePendingFolder(id: folder.id) - }, - onChooseTarget: { folder in - shareTargetPickerFolder = folder - }, - onReconnectObsidian: { - showObsidianPicker = true - } + ignoredFolders: ignoredFolders ) } } @@ -979,46 +873,26 @@ struct ContentView: View { syncthingManager: syncthingManager, onRescanFailedFolders: rescanFailedVaults, onOpenAddDevice: { showAddDevice = true }, - onAcceptFirstPendingShare: acceptFirstPendingShareFromIssues, onRescanAllVaults: rescanAllVaults ) } } } - private func acceptFirstPendingShareFromIssues() { - guard let first = syncthingManager.actionablePendingFolders.first else { return } - shareAccept.accept(first, source: .manual) - } - private func rescanFailedVaults() { // Don't rescan folders surfaced as unreachable — a rescan can't fix a // stale/missing path, so it would be a no-op recovery for those. let unreachable = Set(syncthingManager.unreachableFolders.map(\.id)) - rescanFolders(ids: syncthingManager.folderIDsWithErrors.filter { !unreachable.contains($0) }) + let sendOnlyIDs = Set(syncthingManager.foregroundRescanEligibleFolderIDs) + let targets = syncthingManager.folderIDsWithErrors.filter { + !unreachable.contains($0) && sendOnlyIDs.contains($0) + } + guard !targets.isEmpty else { return } + syncthingManager.triggerForegroundSync(folderIDs: targets) } private func rescanAllVaults() { - rescanFolders(ids: syncthingManager.folders.map(\.id)) - } - - private func rescanFolders(ids: [String]) { - let uniqueIDs = Array(Set(ids)).sorted() - guard !uniqueIDs.isEmpty else { return } - - var failures: [String] = [] - for id in uniqueIDs { - if let err = syncthingManager.rescanFolder(id: id) { - let folderName = syncthingManager.folders.first(where: { $0.id == id })?.label ?? id - let userError = mappedError(err, fallbackTitle: L10n.tr("Rescan Failed")) - failures.append(L10n.fmt("%@: %@", folderName, userError.message)) - } - } - - if !failures.isEmpty { - alertMessage = failures.joined(separator: "\n") - showAlert = true - } + syncthingManager.triggerForegroundSync() } // MARK: - Unreachable Vaults @@ -1077,10 +951,6 @@ struct ContentView: View { Binding(get: { vaultPendingRemoval != nil }, set: { if !$0 { vaultPendingRemoval = nil } }) } - private var mergeConfirmationBinding: Binding { - Binding(get: { shareAccept.pendingMergeConfirmation != nil }, set: { if !$0 { shareAccept.pendingMergeConfirmation = nil } }) - } - private func removeVault(id: String) { vaultPendingRemoval = nil if let err = syncthingManager.removeFolder(id: id) { @@ -1125,13 +995,12 @@ struct ContentView: View { ) } - /// Not navigable on purpose: the missing step (sharing) happens on the - /// desktop, so the row can only explain that — there is no detail screen - /// that would not be empty. + /// Not navigable on purpose: no new sync action is available in this + /// version, so a detail screen would expose no valid control (#150). private func unsyncedVaultRow(_ name: String) -> some View { StatusRow( name, - subtitle: L10n.tr("Not syncing yet — share this vault from your computer to start."), + subtitle: L10n.tr("Not syncing in this version — new share offers are inspection-only."), systemImage: "folder", glyphTint: .statusInactive ) @@ -1146,7 +1015,7 @@ struct ContentView: View { ContentUnavailableView { Label(L10n.tr("Connect to Obsidian first"), systemImage: "folder.badge.gearshape") } description: { - Text("VaultSync needs one-time access to your Obsidian folder before it can accept shares.") + Text(L10n.tr("VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status.")) } } else if vaultManager.detectedVaults.isEmpty { ContentUnavailableView { @@ -1158,7 +1027,7 @@ struct ContentView: View { ContentUnavailableView { Label(L10n.tr("No folders syncing yet"), systemImage: "arrow.triangle.2.circlepath") } description: { - Text("Share a folder from your desktop Syncthing — it will be accepted automatically.") + Text(L10n.tr("New shared vault offers can be inspected, but not accepted in this version.")) } } } @@ -1234,12 +1103,12 @@ struct ContentView: View { // Distinct conflicted files, not copies — same semantics as the // home-screen issue banner (SyncthingManager.unresolvedConflictCount). let conflictCount = Set(conflicts(for: item).map(\.originalPath)).count - let syncStatus = folderSyncStatus(status?.state ?? "unknown") + let safetyState = syncthingManager.conflictSafetyState(folderID: item.folder.id) + let syncStatus = folderSyncStatus(status?.state ?? "unknown", safetyState: safetyState) - var subtitle: String? - if let status { - subtitle = localizedState(status.state, folderID: item.folder.id) - if status.completionPct < 100, status.completionPct > 0 { + var subtitle: String? = localizedState(status?.state ?? "unknown", folderID: item.folder.id) + if let status, safetyState == .clear { + if status.completionPct < 100, status.completionPct > 0, subtitle != nil { subtitle! += " " + L10n.fmt("(%d%%)", Int(status.completionPct)) } } @@ -1262,7 +1131,18 @@ struct ContentView: View { /// the folder row's glyph + color stay identical to the rest of the app (one /// source of truth). Unknown states stay neutral rather than being forced to a /// misleading "attention". - private func folderSyncStatus(_ state: String) -> SyncStatus? { + private func folderSyncStatus( + _ state: String, + safetyState: ConflictSafetyPolicy.State = .clear + ) -> SyncStatus? { + switch safetyState { + case .stopped: + return .error + case .unknown: + return .attention + case .clear: + break + } switch state { case "idle": return .synced case "scanning", "syncing": return .syncing @@ -1271,19 +1151,53 @@ struct ContentView: View { } } - private func stateIcon(_ state: String) -> String { - folderSyncStatus(state)?.symbolName ?? "questionmark.circle" + private func stateIcon(_ state: String, folderID: String) -> String { + folderSyncStatus( + state, + safetyState: syncthingManager.conflictSafetyState(folderID: folderID) + )?.symbolName ?? "questionmark.circle" } - private func stateColor(_ state: String) -> Color { - folderSyncStatus(state)?.tint ?? .statusInactive + private func stateColor(_ state: String, folderID: String) -> Color { + folderSyncStatus( + state, + safetyState: syncthingManager.conflictSafetyState(folderID: folderID) + )?.tint ?? .statusInactive } // MARK: - Vault Detail + nonisolated static func shouldOfferSyncFilterRecommendation( + safetyState: ConflictSafetyPolicy.State, + isUnreachable: Bool, + hasShown: Bool, + isAlreadyPresented: Bool + ) -> Bool { + ConflictSafetyPolicy.allowsMutation(for: safetyState) + && !isUnreachable + && !hasShown + && !isAlreadyPresented + } + + private func offerSyncFilterRecommendationIfAllowed( + for folder: SyncthingManager.FolderInfo, + safetyState: ConflictSafetyPolicy.State + ) { + let isUnreachable = syncthingManager.unreachableFolders.contains { $0.id == folder.id } + guard Self.shouldOfferSyncFilterRecommendation( + safetyState: safetyState, + isUnreachable: isUnreachable, + hasShown: syncthingManager.hasShownRecommendationSheet(folderID: folder.id), + isAlreadyPresented: pendingFilterSheetFolder != nil + ) else { return } + pendingFilterSheetFolder = folder + } + private func vaultDetailView(_ item: VaultRowItem) -> some View { let folder = item.folder let status = syncthingManager.folderStatuses[folder.id] + let safetyState = syncthingManager.conflictSafetyState(folderID: folder.id) + let allowsConflictMutation = ConflictSafetyPolicy.allowsMutation(for: safetyState) let conflicts = self.conflicts(for: item) return List { Section { @@ -1300,30 +1214,29 @@ struct ContentView: View { Section("Sync Status") { LabeledContent("State") { HStack(spacing: 6) { - Image(systemName: stateIcon(status?.state ?? "unknown")) - .foregroundStyle(stateColor(status?.state ?? "unknown")) + Image(systemName: stateIcon(status?.state ?? "unknown", folderID: folder.id)) + .foregroundStyle(stateColor(status?.state ?? "unknown", folderID: folder.id)) .font(.caption2) .accessibilityHidden(true) Text(localizedState(status?.state ?? "unknown", folderID: folder.id)) } .accessibilityElement(children: .combine) } - if let status { + if let status, safetyState == .clear { LabeledContent("Completion", value: "\(Int(status.completionPct))%") LabeledContent("Local Files", value: "\(status.localFiles)") LabeledContent("Global Files", value: "\(status.globalFiles)") - if status.state == "error", - let folderError = syncthingManager.folderUserError(folderID: folder.id) { - VStack(alignment: .leading, spacing: 2) { - Text(folderError.message) - .font(.caption) - Text(folderError.remediation) + } + if let folderError = syncthingManager.folderUserError(folderID: folder.id) { + VStack(alignment: .leading, spacing: 2) { + Text(folderError.message) + .font(.caption) + Text(folderError.remediation) + .font(.caption2) + .foregroundStyle(.secondary) + if let url = troubleshootingURL(for: folderError) { + ExternalLinkButton(titleKey: "Learn how to fix", url: url) .font(.caption2) - .foregroundStyle(.secondary) - if let url = troubleshootingURL(for: folderError) { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) - } } } } @@ -1350,15 +1263,25 @@ struct ContentView: View { } Section { - NavigationLink { - IgnorePatternsView( - folderID: folder.id, - syncthingManager: syncthingManager - ) - } label: { - Label(L10n.tr("Sync Filters"), systemImage: "line.3.horizontal.decrease.circle") + if allowsConflictMutation { + NavigationLink { + IgnorePatternsView( + folderID: folder.id, + syncthingManager: syncthingManager + ) + } label: { + Label(L10n.tr("Sync Filters"), systemImage: "line.3.horizontal.decrease.circle") + } + .accessibilityHint(L10n.tr("Choose what gets synced to this iPhone")) + } else { + let safetyError = SyncUserError.conflictSafetyError(for: safetyState) + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { + Label(safetyError.title, systemImage: "lock.fill") + Text(safetyError.message) + .font(.caption) + .foregroundStyle(.secondary) + } } - .accessibilityHint(L10n.tr("Choose what gets synced to this iPhone")) } Section("Shared With") { @@ -1414,7 +1337,7 @@ struct ContentView: View { } } } - .disabled(isScanning) + .disabled(isScanning || !allowsConflictMutation) } // A 1:1 sync folder maps to exactly one vault, so removing it is @@ -1439,10 +1362,13 @@ struct ContentView: View { .navigationTitle(item.name) .navigationBarTitleDisplayMode(.inline) .onAppear { - // Don't nudge sync filters for a vault that can't sync at all. - let isUnreachable = syncthingManager.unreachableFolders.contains { $0.id == folder.id } - if !isUnreachable, !syncthingManager.hasShownRecommendationSheet(folderID: folder.id) { - pendingFilterSheetFolder = folder + offerSyncFilterRecommendationIfAllowed(for: folder, safetyState: safetyState) + } + .onChange(of: safetyState) { _, newState in + if ConflictSafetyPolicy.allowsMutation(for: newState) { + offerSyncFilterRecommendationIfAllowed(for: folder, safetyState: newState) + } else if pendingFilterSheetFolder?.id == folder.id { + pendingFilterSheetFolder = nil } } .sheet(item: $pendingFilterSheetFolder) { folder in @@ -1553,6 +1479,14 @@ struct ContentView: View { /// successful sync must not read "Up to Date" — before the first exchange /// the honest label is that it is still waiting for one. private func localizedState(_ state: String, folderID: String) -> String { + switch syncthingManager.conflictSafetyState(folderID: folderID) { + case .stopped: + return L10n.tr("Conflict Safety Stop") + case .unknown: + return L10n.tr("Checking Conflict Safety") + case .clear: + break + } if state.lowercased() == "idle", syncthingManager.lastSyncTimeByFolder[folderID] == nil { return L10n.tr("Waiting for first sync") @@ -1584,9 +1518,6 @@ struct ContentView: View { ContentView( syncthingManager: syncthing, vaultManager: vault, - subscriptionManager: SubscriptionManager(), - shareAccept: ShareAcceptCoordinator( - environment: .live(syncthingManager: syncthing, vaultManager: vault) - ) + subscriptionManager: SubscriptionManager() ) } diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift index 0eba73a..2d92820 100644 --- a/ios/VaultSync/Views/ControlledDiagnosticsView.swift +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -13,8 +13,6 @@ struct ControlledDiagnosticsView: View { @State private var consentAction: ConsentAction = .scan @State private var showRecoveryConfirmation = false @State private var missingFolderRecordID: String? - @State private var pendingUploadRecordID: String? - @State private var showUploadConsent = false private enum ConsentAction { case scan @@ -83,16 +81,6 @@ struct ControlledDiagnosticsView: View { } message: { Text(L10n.tr("This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint.")) } - .alert(L10n.tr("Start controlled upload and download check?"), isPresented: $showUploadConsent) { - Button(L10n.tr("Cancel"), role: .cancel) { - pendingUploadRecordID = nil - } - Button(L10n.tr("Start Upload and Download Check")) { - startPendingUpload() - } - } message: { - Text(L10n.tr("VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones.")) - } } private var explanationSection: some View { @@ -340,14 +328,11 @@ struct ControlledDiagnosticsView: View { Button(L10n.tr("Cancel Controlled Check"), role: .cancel) { controller.cancelForegroundUpload(recordID: record.id) } - } else if capability == .available { - Button(L10n.tr("Start Foreground Upload and Download Check")) { - pendingUploadRecordID = record.id - showUploadConsent = true - } - .buttonStyle(.borderedProminent) } else { - Text(L10n.tr("Check authenticated capability immediately before starting an upload check.")) + Label( + L10n.tr("Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review."), + systemImage: "hand.raised" + ) .font(.caption) .foregroundStyle(.secondary) } @@ -515,42 +500,6 @@ struct ControlledDiagnosticsView: View { } } - private func startPendingUpload() { - guard let recordID = pendingUploadRecordID, - let record = controller.records.first(where: { $0.id == recordID }) else { - pendingUploadRecordID = nil - return - } - pendingUploadRecordID = nil - controller.beginForegroundUpload( - recordID: record.id, - preflight: { installationComponent, operationComponent, requireEmptySlot in - syncthingManager.diagnosticsUploadPreflight( - folderID: record.folderID, - peerID: record.homeserverDeviceID, - installationComponent: installationComponent, - operationComponent: operationComponent, - requireEmptySlot: requireEmptySlot - ) - }, - rescan: { - syncthingManager.rescanFolder(id: record.folderID) == nil - }, - events: { sinceID in - // Read events before the generation: if the engine restarts - // in between, the newer generation fails the caller's - // continuity check instead of tagging new-engine events with - // the pre-restart generation. - let json = SyncBridgeService.getEventsSince(lastID: Int(sinceID)) - let generation = SyncBridgeService.eventStreamGeneration() - return DiagnosticsResponseProtocol.eventSnapshot( - generation: generation, - json: json - ) - } - ) - } - private func uploadStatusLabel(_ status: DiagnosticsPairingController.UploadStatus) -> String { if status.evidence.uploadObserved { switch status.phase { diff --git a/ios/VaultSync/Views/IgnorePatternsView.swift b/ios/VaultSync/Views/IgnorePatternsView.swift index 8f4fa5b..61cc6a7 100644 --- a/ios/VaultSync/Views/IgnorePatternsView.swift +++ b/ios/VaultSync/Views/IgnorePatternsView.swift @@ -11,23 +11,49 @@ struct IgnorePatternsView: View { @State private var hasLoadedScan = false var body: some View { - List { - recommendedSection - if !detected.isEmpty { - foundSection + Group { + if allowsChanges { + List { + recommendedSection + if !detected.isEmpty { + foundSection + } + otherPresetsSection + customSection + footerSection + } + } else { + ContentUnavailableView { + Label(safetyError.title, systemImage: "lock.fill") + } description: { + Text(safetyError.message) + Text(safetyError.remediation) + } } - otherPresetsSection - customSection - footerSection } .navigationTitle(L10n.tr("Sync Filters")) .navigationBarTitleDisplayMode(.inline) - .task { await initialLoad() } + .task(id: safetyState) { + guard allowsChanges else { return } + await initialLoad() + } .alert(L10n.tr("Sync Filter Error"), isPresented: errorBinding) { Button(L10n.tr("OK")) { alertMessage = nil } } message: { Text(alertMessage ?? "") } } + private var safetyState: ConflictSafetyPolicy.State { + syncthingManager.conflictSafetyState(folderID: folderID) + } + + private var allowsChanges: Bool { + ConflictSafetyPolicy.allowsMutation(for: safetyState) + } + + private var safetyError: SyncUserError { + SyncUserError.conflictSafetyError(for: safetyState) + } + private var errorBinding: Binding { Binding(get: { alertMessage != nil }, set: { if !$0 { alertMessage = nil } }) } diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index 2141de6..483c871 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -5,7 +5,6 @@ struct OnboardingView: View { var syncthingManager: SyncthingManager var vaultManager: VaultManager var subscriptionManager: SubscriptionManager - var shareAccept: ShareAcceptCoordinator @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @@ -30,8 +29,13 @@ struct OnboardingView: View { private var obsidianConnected: Bool { vaultManager.isAccessible } private var deviceAdded: Bool { !syncthingManager.devices.isEmpty } - private var vaultSyncing: Bool { !syncthingManager.folders.isEmpty } - private var allStepsComplete: Bool { obsidianConnected && deviceAdded && vaultSyncing } + private var hasConfiguredVault: Bool { !syncthingManager.folders.isEmpty } + private var hasSendOnlyVault: Bool { + syncthingManager.folders.contains { + ConflictSafetyPolicy.runtimeState(forFolderType: $0.type) == .clear + } + } + private var allStepsComplete: Bool { obsidianConnected && deviceAdded && hasConfiguredVault } var body: some View { NavigationStack { @@ -65,15 +69,12 @@ struct OnboardingView: View { }) { url in showObsidianPicker = false Task { - // Same sequence as the home screen's reconnect flow - // (#53/#92): granting access produces no pendingFolders - // change event, so an offer that arrived before the - // grant would sit untouched. The accept pass runs only - // after the reconcile settled paths (#56, decision 008). + // Match the home screen's reconnect sequence: publish + // access feedback, then settle any existing folder + // paths before returning to setup. if let err = await ObsidianReconnectFlow.run( grantAccess: { vaultManager.grantAccess(url: url) }, onGrantSucceeded: { - shareAccept.clearRecordedFailures() if let advisory = vaultManager.selectionAdvisory { infoMessage = advisory showInfoAlert = true @@ -84,9 +85,6 @@ struct OnboardingView: View { await syncthingManager.reconcileFolderPaths( obsidianRoot: vaultManager.obsidianBasePath ).value - }, - retryPendingShares: { - shareAccept.runAutomaticPass() } ) { present(error: err, fallbackTitle: L10n.tr("Obsidian Folder Connection Failed")) @@ -105,12 +103,12 @@ struct OnboardingView: View { ) } .alert(L10n.tr("Something Went Wrong"), isPresented: $showAlert) { - Button("OK") { } + Button(L10n.tr("OK")) { } } message: { Text(alertMessage ?? "") } .alert(L10n.tr("Note"), isPresented: $showInfoAlert) { - Button("OK") { } + Button(L10n.tr("OK")) { } } message: { Text(infoMessage ?? "") } @@ -128,23 +126,6 @@ struct OnboardingView: View { vaultManager: vaultManager ) } - .onChange(of: syncthingManager.pendingFolders, initial: true) { _, _ in - // The accept pass must not depend on ContentView being mounted - // (#92): the same standing triggers ContentView carries, driving - // the same coordinator with the identical gates (decision 015). - shareAccept.runAutomaticPass() - } - .onChange(of: syncthingManager.pathSettlement.settled) { _, settled in - if settled { - shareAccept.runAutomaticPass() - } - } - .onChange(of: shareAccept.alertMessage) { _, message in - guard let message else { return } - shareAccept.alertMessage = nil - alertMessage = message - showAlert = true - } } private func page(_ content: Content) -> some View { @@ -196,11 +177,11 @@ struct OnboardingView: View { private var setupScreen: some View { VStack(alignment: .leading, spacing: VaultSpacing.l) { VStack(alignment: .leading, spacing: VaultSpacing.s) { - Text(L10n.tr("Let’s get your vault synced")) + Text(L10n.tr("Set up VaultSync")) .font(titleFont) .foregroundStyle(primaryHeadingColor) - Text(L10n.tr("Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen.")) + Text(L10n.tr("Connect Obsidian and a device here. New shared vault offers are inspection-only in this version.")) .font(.body) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -214,7 +195,7 @@ struct OnboardingView: View { isComplete: obsidianConnected, icon: "folder.badge.plus", title: L10n.tr("Connect your Obsidian folder"), - description: L10n.tr("Give VaultSync one-time access to your local Obsidian folder so it can sync your notes."), + description: L10n.tr("VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status."), actionTitle: L10n.tr("Connect Obsidian Folder"), action: { showObsidianPicker = true } ) @@ -229,19 +210,19 @@ struct OnboardingView: View { ) stepCard( - isComplete: vaultSyncing, + isComplete: hasConfiguredVault, icon: "arrow.triangle.2.circlepath", - title: L10n.tr("Sync your first vault"), - description: L10n.tr("Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives."), + title: L10n.tr("Vault configured"), + description: hasConfiguredVault + ? (hasSendOnlyVault + ? L10n.tr("At least one Send Only vault can continue uploading local changes.") + : L10n.tr("Existing receive-capable vaults are available for review only in this version.")) + : L10n.tr("New shared vault offers can be inspected, but not accepted in this version."), actionTitle: nil, - action: nil, - // The only step that happens on ANOTHER machine — without a - // pointer to the desktop-side steps it is a dead end (#69). - linkTitleKey: "How to share from your computer", - linkURL: DocURL.desktopShareHelp + action: nil ) - if !vaultSyncing { + if !hasConfiguredVault { ForEach(syncthingManager.actionablePendingFolders) { folder in offerStatusRow(folder) } @@ -252,7 +233,7 @@ struct OnboardingView: View { .font(.body.weight(.semibold)) .foregroundStyle(teal) .accessibilityHidden(true) - Text(L10n.tr("Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab.")) + Text(L10n.tr("Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -327,43 +308,19 @@ struct OnboardingView: View { .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) } - /// Live status for a share offer that arrives during onboarding (#92): - /// the accept pass runs right here with the home screen's gates, and this - /// row keeps step 3 honest while it does — including the cases the pass - /// deliberately parks (no Obsidian access yet; a decision only the full - /// pending-shares UI can take, e.g. a non-empty target — #54). + /// Read-only status for an offer that arrives during onboarding (#150). + /// Details remain visible, but this version exposes no acceptance action. private func offerStatusRow(_ folder: SyncthingManager.PendingFolderInfo) -> some View { let name = folder.label.isEmpty ? folder.id : folder.label - let needsAttention = shareAccept.pendingShareFailures[folder.id] != nil - || !syncthingManager.autoAcceptEligiblePendingFolders.contains(where: { $0.id == folder.id }) return HStack(alignment: .top, spacing: VaultSpacing.m) { - if needsAttention { - Image(systemName: "exclamationmark.triangle.fill") - .font(.body.weight(.semibold)) - .foregroundStyle(Color.statusAttention) - .accessibilityHidden(true) - Text(L10n.fmt("Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen.", name)) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if !obsidianConnected { - Image(systemName: "folder.badge.questionmark") - .font(.body.weight(.semibold)) - .foregroundStyle(Color.statusAttention) - .accessibilityHidden(true) - Text(L10n.fmt("Offer “%@” received — connect your Obsidian folder first.", name)) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - Text(L10n.fmt("Offer “%@” received — accepting…", name)) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } + Image(systemName: "lock.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.statusInfo) + .accessibilityHidden(true) + Text(L10n.fmt("Offer “%@” is available for inspection only. This version cannot accept it.", name)) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } .padding(VaultSpacing.l) .frame(maxWidth: .infinity, alignment: .leading) @@ -383,13 +340,13 @@ struct OnboardingView: View { private func presentDeviceAddedHintIfNeeded() { guard showDeviceAddedHint else { return } showDeviceAddedHint = false - infoMessage = L10n.tr("Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing.") + infoMessage = L10n.tr("Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version.") showInfoAlert = true } /// Engine start failure was invisible during onboarding (#95): userError /// renders only on the ContentView dashboard, so a failed start left the - /// "Sync your first vault" step waiting forever with no explanation. + /// vault-configuration step waiting forever with no explanation. private var engineError: SyncUserError? { if let userError = syncthingManager.userError { return userError } return syncthingManager.error.map { diff --git a/ios/VaultSync/Views/PendingSharesView.swift b/ios/VaultSync/Views/PendingSharesView.swift index b886f3c..0c1bb0c 100644 --- a/ios/VaultSync/Views/PendingSharesView.swift +++ b/ios/VaultSync/Views/PendingSharesView.swift @@ -3,26 +3,16 @@ import SwiftUI struct PendingSharesView: View { let pendingFolders: [SyncthingManager.PendingFolderInfo] let ignoredFolders: [SyncthingManager.PendingFolderInfo] - let failureByFolderID: [String: SyncUserError] - let inFlightFolderIDs: Set - let obsidianAccessible: Bool - var onAccept: (SyncthingManager.PendingFolderInfo) -> Void - var onRetry: (SyncthingManager.PendingFolderInfo) -> Void - var onIgnore: (SyncthingManager.PendingFolderInfo) -> Void - var onRestoreIgnored: (SyncthingManager.PendingFolderInfo) -> Void - var onChooseTarget: (SyncthingManager.PendingFolderInfo) -> Void - var onReconnectObsidian: () -> Void var body: some View { VStack(alignment: .leading, spacing: VaultSpacing.l) { - if !obsidianAccessible { - ActionCard( - status: .attention, - title: L10n.tr("Connect Obsidian to accept shares"), - message: L10n.tr("Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected."), - actionTitle: L10n.tr("Reconnect Obsidian Folder"), - action: onReconnectObsidian - ) + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { + Label(L10n.tr("Pending shares are read-only in this version."), systemImage: "lock.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.statusInfo) + Text(L10n.tr("You can inspect who shared each offer, but no action is available.")) + .font(.caption) + .foregroundStyle(.secondary) } if pendingFolders.isEmpty { @@ -48,22 +38,10 @@ struct PendingSharesView: View { .foregroundStyle(.secondary) } Spacer() - // Restoring hands the share back to auto-accept; - // "Choose Vault…" accepts it directly into a - // picked target instead (#52). - VStack(alignment: .trailing, spacing: VaultSpacing.xs) { - Button("Restore Share") { - onRestoreIgnored(folder) - } - .buttonStyle(.bordered) - .controlSize(.regular) - Button("Choose Vault…") { - onChooseTarget(folder) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .disabled(!obsidianAccessible) - } + StatusTag( + text: L10n.tr("Read Only"), + tint: Color.statusInfo + ) } } } @@ -78,12 +56,10 @@ struct PendingSharesView: View { @ViewBuilder private func pendingRow(_ folder: SyncthingManager.PendingFolderInfo) -> some View { - let failure = failureByFolderID[folder.id] - let hasFailure = failure != nil VStack(alignment: .leading, spacing: VaultSpacing.s) { HStack(alignment: .top, spacing: VaultSpacing.s) { - Image(systemName: hasFailure ? "exclamationmark.circle.fill" : "tray.and.arrow.down.fill") - .foregroundStyle(hasFailure ? Color.statusAttention : Color.statusInfo) + Image(systemName: "tray.full.fill") + .foregroundStyle(Color.statusInfo) .accessibilityHidden(true) VStack(alignment: .leading, spacing: VaultSpacing.xs) { @@ -91,71 +67,18 @@ struct PendingSharesView: View { Text(displayName(for: folder)) .font(.body.weight(.semibold)) StatusTag( - text: hasFailure ? L10n.tr("Needs Attention") : L10n.tr("Ready"), - tint: hasFailure ? Color.statusAttention : Color.statusInfo + text: L10n.tr("Read Only"), + tint: Color.statusInfo ) } Text(offeredByDescription(for: folder)) .font(.caption) .foregroundStyle(.secondary) - - if let failure { - Text(failure.message) - .font(.caption) - .foregroundStyle(Color.statusAttention) - if !failure.remediation.isEmpty { - Text(failure.remediation) - .font(.caption2) - .foregroundStyle(.secondary) - } - } } Spacer() } .accessibilityElement(children: .combine) - - HStack(spacing: VaultSpacing.s) { - if inFlightFolderIDs.contains(folder.id) { - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - Text("Applying…") - .font(.caption) - .foregroundStyle(.secondary) - } else if failure == nil { - Button("Accept Share") { - onAccept(folder) - } - .buttonStyle(.borderedProminent) - .disabled(!obsidianAccessible) - } else { - // Not "Retry": most parks come from the AUTOMATIC pass - // (e.g. a merge waiting for consent) — "Retry" falsely - // implied a prior attempt by the user (#71). - Button("Review and Accept") { - onRetry(folder) - } - .buttonStyle(.borderedProminent) - .disabled(!obsidianAccessible) - } - - Button("Ignore for Now") { - onIgnore(folder) - } - .buttonStyle(.bordered) - .disabled(inFlightFolderIDs.contains(folder.id)) - } - - // The per-share manual path (#52): pick an existing empty vault or - // create a custom-named folder instead of the share-label default. - if !inFlightFolderIDs.contains(folder.id) { - Button("Choose Vault…") { - onChooseTarget(folder) - } - .buttonStyle(.bordered) - .disabled(!obsidianAccessible) - } } .padding(VaultSpacing.m) .vaultCard() diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift index 70ba300..cccb253 100644 --- a/ios/VaultSync/Views/RelayHomeView.swift +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -62,10 +62,10 @@ struct RelayHomeView: View { .foregroundStyle(Color.vaultAccent) .accessibilityHidden(true) - Text(L10n.tr("Instant sync, still private")) + Text(L10n.tr("Private background wake-ups")) .font(.title2.weight(.bold)) - Text(L10n.tr("Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed.")) + Text(L10n.tr("Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -84,7 +84,7 @@ struct RelayHomeView: View { .foregroundStyle(Color.vaultAccent) .padding(.top, VaultSpacing.xs) .popover(isPresented: $showPrivacyInfo) { - Text(L10n.tr("Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage.")) + Text(L10n.tr("VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes.")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) @@ -173,7 +173,7 @@ struct RelayHomeView: View { .font(.headline) } } footer: { - Text(L10n.tr("One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes.")) + Text(L10n.tr("One step left: run one command on your server to enable background wake-ups. The helper never sees your notes.")) } } else if relayUserStatus == .relayObservedWaitingForWakeUp { Section { diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index c711d8d..f1ff4af 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -126,7 +126,7 @@ struct SettingsView: View { } header: { Text(L10n.tr("Conflicts")) } footer: { - Text(L10n.tr("VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep.")) + Text(L10n.tr("VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version.")) } } @@ -140,7 +140,7 @@ struct SettingsView: View { } header: { Text(L10n.tr("Notifications")) } footer: { - Text(L10n.tr("Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing.")) + Text(L10n.tr("Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads.")) } } diff --git a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift index ce2cbf0..0bd8222 100644 --- a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift +++ b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift @@ -13,49 +13,72 @@ struct SyncFilterRecommendationSheet: View { var body: some View { NavigationStack { - List { - Section { - Text(L10n.tr("Skip these on this iPhone? You can change this anytime in Sync Filters.")) - .font(.callout) - .foregroundStyle(.secondary) - } + Group { + if allowsChanges { + List { + Section { + Text(L10n.tr("Skip these on this iPhone? You can change this anytime in Sync Filters.")) + .font(.callout) + .foregroundStyle(.secondary) + } - Section(header: Text(L10n.tr("Recommended"))) { - ForEach(IgnorePreset.recommended) { preset in - presetToggle(preset) - } - } + Section(header: Text(L10n.tr("Recommended"))) { + ForEach(IgnorePreset.recommended) { preset in + presetToggle(preset) + } + } - if !detected.isEmpty { - Section(header: Text(L10n.tr("Found in this vault"))) { - ForEach(detected) { item in - detectedToggle(item) + if !detected.isEmpty { + Section(header: Text(L10n.tr("Found in this vault"))) { + ForEach(detected) { item in + detectedToggle(item) + } + } } } + } else { + ContentUnavailableView { + Label(safetyError.title, systemImage: "lock.fill") + } description: { + Text(safetyError.message) + Text(safetyError.remediation) + } } } .navigationTitle(L10n.tr("Sync Filters")) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button(L10n.tr("Skip")) { - syncthingManager.markRecommendationSheetShown(folderID: folderID) - dismiss() + if allowsChanges { + ToolbarItem(placement: .topBarLeading) { + Button(L10n.tr("Skip")) { + syncthingManager.markRecommendationSheetShown(folderID: folderID) + dismiss() + } } - } - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.tr("Done")) { - if let err = apply() { - applyErrorMessage = err.message - return + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.tr("Done")) { + if let err = apply() { + applyErrorMessage = err.message + return + } + syncthingManager.markRecommendationSheetShown(folderID: folderID) + dismiss() + } + .bold() + } + } else { + ToolbarItem(placement: .topBarLeading) { + Button(L10n.tr("Cancel")) { + applyErrorMessage = nil + dismiss() } - syncthingManager.markRecommendationSheetShown(folderID: folderID) - dismiss() } - .bold() } } - .task { await scan() } + .task(id: safetyState) { + guard allowsChanges else { return } + await scan() + } .alert(L10n.tr("Could not save filters"), isPresented: errorBinding) { Button(L10n.tr("OK")) { applyErrorMessage = nil } } message: { @@ -64,6 +87,18 @@ struct SyncFilterRecommendationSheet: View { } } + private var safetyState: ConflictSafetyPolicy.State { + syncthingManager.conflictSafetyState(folderID: folderID) + } + + private var allowsChanges: Bool { + ConflictSafetyPolicy.allowsMutation(for: safetyState) + } + + private var safetyError: SyncUserError { + SyncUserError.conflictSafetyError(for: safetyState) + } + private var errorBinding: Binding { Binding(get: { applyErrorMessage != nil }, set: { if !$0 { applyErrorMessage = nil } }) } diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index 0daa47c..8cc4752 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -5,7 +5,6 @@ struct SyncIssuesView: View { let syncthingManager: SyncthingManager let onRescanFailedFolders: () -> Void let onOpenAddDevice: () -> Void - let onAcceptFirstPendingShare: () -> Void let onRescanAllVaults: () -> Void var body: some View { @@ -46,7 +45,7 @@ struct SyncIssuesView: View { private func symbol(for issue: SyncthingManager.SyncIssueItem) -> String { switch issue.kind { - case .pathCollision, .nestedFolders, .folderErrors, .conflicts, .staleSync: + case .pathCollision, .nestedFolders, .conflictRetentionSafety, .folderErrors, .conflicts, .staleSync: return "exclamationmark.triangle.fill" case .backgroundSync: return "clock.badge.exclamationmark" @@ -80,6 +79,25 @@ struct SyncIssuesView: View { // re-added into its own folder). EmptyView() + case .conflictRetentionSafety: + // The stop is deliberately read-only. Any conflict copies still + // present in the refreshed cache remain reviewable (#150). + if let destination = Self.conflictDestination( + preferredFolderID: issue.folderID, + conflictFiles: syncthingManager.conflictFiles, + unavailableFolderIDs: syncthingManager.conflictInspectionUnavailableFolderIDs, + allowFallback: false + ) { + NavigationLink(L10n.tr("Review Conflicts")) { + ConflictListView( + folderID: destination, + syncthingManager: syncthingManager + ) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + case .folderErrors: // A rescan cannot recreate a missing folder marker — when marker // loss is the only error, hide the button and let the prose @@ -101,23 +119,20 @@ struct SyncIssuesView: View { .controlSize(.regular) case .pendingShares: - if !syncthingManager.actionablePendingFolders.isEmpty { - // "First" only when there IS a queue — for a single share the - // qualifier read as if more were hiding somewhere (#71). - Button(syncthingManager.actionablePendingFolders.count == 1 - ? L10n.tr("Accept Pending Share") - : L10n.tr("Accept First Pending Share")) { - onAcceptFirstPendingShare() - } - .buttonStyle(.bordered) - .controlSize(.regular) - } + // Retained enum case for durable snapshot compatibility. Pending + // offers are inspection-only in 2.0.2 and have no issue action. + EmptyView() case .conflicts: - if let destination = firstConflictDestination(preferredFolderID: issue.folderID) { - NavigationLink("Resolve Conflicts") { + if let destination = Self.conflictDestination( + preferredFolderID: issue.folderID, + conflictFiles: syncthingManager.conflictFiles, + unavailableFolderIDs: syncthingManager.conflictInspectionUnavailableFolderIDs, + allowFallback: true + ) { + NavigationLink(L10n.tr("Review Conflicts")) { ConflictListView( - folderID: destination.folderID, + folderID: destination, syncthingManager: syncthingManager ) } @@ -126,7 +141,7 @@ struct SyncIssuesView: View { } case .staleSync: - if !syncthingManager.folders.isEmpty { + if !syncthingManager.foregroundRescanEligibleFolderIDs.isEmpty { Button("Rescan All Vaults") { onRescanAllVaults() } @@ -135,7 +150,7 @@ struct SyncIssuesView: View { } case .backgroundSync: - if !syncthingManager.folders.isEmpty { + if !syncthingManager.foregroundRescanEligibleFolderIDs.isEmpty { Button("Run Foreground Rescan") { onRescanAllVaults() } @@ -145,27 +160,32 @@ struct SyncIssuesView: View { } } - private func firstConflictDestination( - preferredFolderID: String? - ) -> (folderID: String, conflicts: [SyncthingManager.ConflictInfo])? { + nonisolated static func conflictDestination( + preferredFolderID: String?, + conflictFiles: [String: [SyncthingManager.ConflictInfo]], + unavailableFolderIDs: Set = [], + allowFallback: Bool + ) -> String? { if let preferredFolderID, - let conflicts = syncthingManager.conflictFiles[preferredFolderID], - !conflicts.isEmpty { - return (preferredFolderID, conflicts) + conflictFiles[preferredFolderID]?.isEmpty == false + || unavailableFolderIDs.contains(preferredFolderID) { + return preferredFolderID } - guard let entry = syncthingManager.conflictFiles + guard allowFallback else { return nil } + + if let entry = conflictFiles .sorted(by: { $0.key < $1.key }) - .first(where: { !$0.value.isEmpty }) else { - return nil + .first(where: { !$0.value.isEmpty }) { + return entry.key } - return (entry.key, entry.value) + return unavailableFolderIDs.sorted().first } private func troubleshootingURL(for kind: SyncthingManager.SyncIssueItem.Kind) -> URL? { let anchor: String switch kind { - case .pathCollision, .nestedFolders: + case .pathCollision, .nestedFolders, .conflictRetentionSafety, .pendingShares: // No troubleshooting-doc section for this yet, and the inline // remediation is the complete fix path — don't surface a // misdirecting link (same stance as `.conflicts`). @@ -174,13 +194,11 @@ struct SyncIssuesView: View { anchor = "bookmark-access-expired" case .disconnectedPeers: anchor = "required-device-disconnected" - case .pendingShares: - anchor = "no-pending-shares-appear" case .conflicts: // No conflict-resolution section exists in the troubleshooting doc, // and "Background Sync Not Working" is unrelated. The inline - // "Resolve Conflicts" action is the correct fix path, so don't - // surface a misdirecting link here. + // review action is the complete read-only path, so don't surface a + // misdirecting link here. return nil case .staleSync, .backgroundSync: anchor = "background-sync-not-working" diff --git a/ios/VaultSync/de.lproj/InfoPlist.strings b/ios/VaultSync/de.lproj/InfoPlist.strings index 9cf9382..d80c1a3 100644 --- a/ios/VaultSync/de.lproj/InfoPlist.strings +++ b/ios/VaultSync/de.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; "NSCameraUsageDescription" = "VaultSync verwendet die Kamera, um von dir ausdrücklich ausgewählte Einrichtungs- und Pairing-QR-Codes zu scannen."; -"NSLocalNetworkUsageDescription" = "VaultSync verbindet sich direkt mit deinen anderen Geräten im selben Netzwerk, damit deine Vaults sofort synchronisieren – ohne Umweg über das Internet."; +"NSLocalNetworkUsageDescription" = "VaultSync verwendet das lokale Netzwerk, um sich für Statusprüfungen und Uploads aus Nur-Senden-Vaults direkt mit deinen anderen Geräten zu verbinden."; diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index efe2e70..b00c07d 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -1,8 +1,4 @@ "About" = "Über"; -"Accept First Pending Share" = "Erste Freigabe annehmen"; -"Accept Pending Share" = "Ausstehende Freigabe annehmen"; -"Accept Share" = "Freigabe annehmen"; -"Accept a share to activate syncing for that vault." = "Nimm eine Freigabe an, um die Synchronisation für diesen Vault zu aktivieren."; "Action Needed" = "Aktion erforderlich"; "Actions" = "Aktionen"; "Active" = "Aktiv"; @@ -45,11 +41,9 @@ "Completion" = "Fortschritt"; "Configuration Error" = "Konfigurationsfehler"; "Conflict Resolution Failed" = "Konfliktauflösung fehlgeschlagen"; -"Conflict Resolved" = "Konflikt gelöst"; "Conflicted Files" = "Dateien mit Konflikten"; "Conflicts" = "Konflikte"; -"Conflicts mean multiple versions exist and need a manual decision." = "Konflikte bedeuten, dass mehrere Versionen existieren und manuell entschieden werden muss."; -"Connect Obsidian to accept shares" = "Obsidian verbinden, um Freigaben anzunehmen"; +"A separate conflict copy was detected for a file." = "Für eine Datei wurde eine separate Konfliktkopie erkannt."; "Connect Obsidian Folder" = "Obsidian-Ordner verbinden"; "Connect to Obsidian first" = "Zuerst mit Obsidian verbinden"; "Connected" = "Verbunden"; @@ -105,23 +99,16 @@ "Healthy" = "Funktionsfähig"; "How to fix: %@" = "So behebst du es: %@"; "How to share from your computer" = "So gibst du vom Computer frei"; -"If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep." = "Wenn sich eine Notiz auf zwei Geräten gleichzeitig ändert, kann VaultSync dich benachrichtigen, damit du entscheidest, welche Version bleibt."; +"If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version." = "Wenn VaultSync Konfliktkopien erkennt, kann es dich benachrichtigen, damit du sie prüfen kannst. Wiederherstellungsaktionen sind in dieser Version nicht verfügbar."; "Ignore for Now" = "Vorerst ignorieren"; "Ignored shares (%d)" = "Ignorierte Freigaben (%d)"; "In progress" = "Läuft"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Installiere Obsidian aus dem App Store und öffne es einmal. Der Ordner erscheint, nachdem Obsidian ihn erstellt hat."; "Invalid Input" = "Ungültige Eingabe"; "Invalid folder name: '%@'" = "Ungültiger Ordnername: „%@“"; -"Keep Both" = "Beide behalten"; "Keep Both did not change any files because the new copy name is already in use." = "„Beide behalten“ hat keine Dateien geändert, weil der Name der neuen Kopie bereits verwendet wird."; "Keep Both did not change any files because this storage location does not support safe renaming." = "„Beide behalten“ hat keine Dateien geändert, weil dieser Speicherort sicheres Umbenennen nicht unterstützt."; -"Keep Other" = "Andere behalten"; -"Keep Other Device's Version" = "Version des anderen Geräts behalten"; -"Keep This" = "Diese behalten"; -"Keep This Device's Version" = "Version dieses Geräts behalten"; -"Keep both versions" = "Beide Versionen behalten"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Lass die App einen Moment geöffnet und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; -"Keeps your local file and renames the other device's file." = "Behält deine lokale Datei und benennt die Datei des anderen Geräts um."; "Last Check" = "Letzte Prüfung"; "Last Failure" = "Letzter Fehler"; "Last Relay Error" = "Letzter Relay-Fehler"; @@ -179,7 +166,7 @@ "Open VaultSync" = "VaultSync öffnen"; "Open VaultSync once to restart Syncthing, then retry." = "Öffne VaultSync einmal, um Syncthing neu zu starten, und versuche es dann erneut."; "Open VaultSync to allow a longer foreground sync session." = "Öffne VaultSync, um eine längere Sync-Sitzung im Vordergrund zu erlauben."; -"Open conflicts and choose which version to keep." = "Öffne Konflikte und wähle aus, welche Version behalten werden soll."; +"Open conflicts to see which copies are still available. Recovery actions are unavailable." = "Öffne die Konflikte, um zu sehen, welche Kopien noch verfügbar sind. Wiederherstellungsaktionen sind nicht verfügbar."; "Open full relay troubleshooting" = "Vollständige Relay-Fehlerbehebung öffnen"; "Open iOS Notification Settings" = "iOS-Mitteilungseinstellungen öffnen"; "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Öffne iOS Einstellungen → VaultSync, prüfe, ob alle Berechtigungen aktiviert sind, und versuche es dann erneut."; @@ -187,11 +174,9 @@ "Opens discovery, relay, and notification settings." = "Öffnet Erkennungs-, Relay- und Mitteilungseinstellungen."; "Opens the form to add a Syncthing device." = "Öffnet das Formular zum Hinzufügen eines Syncthing-Geräts."; "Optional" = "Optional"; -"Overwrites your local file with the version from the other device." = "Überschreibt deine lokale Datei mit der Version vom anderen Gerät."; "Path" = "Pfad"; "Peer connection is active." = "Peer-Verbindung ist aktiv."; "Pending Shares" = "Ausstehende Freigaben"; -"Pending shares are waiting to be accepted before sync can start." = "Ausstehende Freigaben warten auf Annahme, bevor die Synchronisation starten kann."; "Per-Device Provisioning" = "Provisioning pro Gerät"; "Permission Required" = "Berechtigung erforderlich"; "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Bitte wähle einen Ordner aus. Wähle im Picker „Auf meinem iPhone“ → „Obsidian“."; @@ -206,7 +191,7 @@ "Reconnect Obsidian Folder" = "Obsidian-Ordner erneut verbinden"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "Verbinde den Obsidian-Zugriff erneut oder passe die Ordnerberechtigungen auf dem Host-Gerät an."; "Reconnect devices or add missing peers to restore continuous sync." = "Verbinde Geräte erneut oder füge fehlende Peers hinzu, um die kontinuierliche Synchronisation wiederherzustellen."; -"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "Verbinde den Zugriff auf deinen Obsidian-Ordner in VaultSync erneut und führe dann einen erneuten Scan im Vordergrund aus."; +"Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version." = "Verbinde den Zugriff auf deinen Obsidian-Ordner in VaultSync erneut und prüfe dann den betroffenen Vault. Vaults mit Empfangsfunktion bleiben in dieser Version schreibgeschützt."; "Recreate or reselect the folder, then trigger a rescan." = "Erstelle den Ordner neu oder wähle ihn erneut aus und starte dann einen erneuten Scan."; "Registered" = "Registriert"; "Relay Backend" = "Relay-Backend"; @@ -235,19 +220,16 @@ "Remove and re-share the folder from your desktop device." = "Entferne den Ordner und teile ihn von deinem Desktop-Gerät erneut."; "Removed line. %@" = "Entfernte Zeile. %@"; "Rename Failed" = "Umbenennen fehlgeschlagen"; -"Rename the existing copy in Files, then try Keep Both again." = "Benenne die vorhandene Kopie in der Dateien-App um und versuche dann erneut „Beide behalten“."; -"Resolve this conflict manually in Files without replacing either file." = "Löse diesen Konflikt manuell in der Dateien-App, ohne eine der Dateien zu ersetzen."; +"Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available." = "Lass beide Kopien unverändert. Wiederhole diese Aktion nicht, bis eine separat geprüfte Wiederherstellung verfügbar ist."; "Renews" = "Verlängert sich"; "Requesting camera access…" = "Kamerazugriff wird angefordert…"; "Rescan Failed" = "Erneuter Scan fehlgeschlagen"; "Rescan Failed Vaults" = "Fehler-Vaults neu scannen"; "Rescan Vault" = "Vault erneut scannen"; "Rescan failed vaults, then verify folder access and permissions." = "Scanne fehlgeschlagene Vaults erneut und prüfe dann Ordnerzugriff und Berechtigungen."; -"Resolve Conflict" = "Konflikt lösen"; -"Resolve Conflicts" = "Konflikte lösen"; "Restore Purchases" = "Käufe wiederherstellen"; "Restore Share" = "Freigabe wiederherstellen"; -"A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "Ein Konflikt entsteht, wenn eine Datei gleichzeitig auf zwei Geräten bearbeitet wird. Syncthing speichert beide Versionen, um Datenverlust zu vermeiden."; +"A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available." = "Ein Konflikt kann entstehen, wenn eine Datei gleichzeitig auf zwei Geräten geändert wird. Prüfe jede sichtbare Kopie, denn die automatische Aufbewahrung kann nicht garantieren, dass jede Version verfügbar bleibt."; "APNs Registration" = "APNs-Registrierung"; "APNs Token" = "APNs-Token"; "Context: %@ · %@" = "Kontext: %@ · %@"; @@ -256,7 +238,6 @@ "Present" = "Vorhanden"; "Purchase Failed" = "Kauf fehlgeschlagen"; "Rescan All Vaults" = "Alle Vaults erneut scannen"; -"Review and Accept" = "Prüfen und annehmen"; "Selects this plan." = "Wählt diesen Tarif aus."; "Something Went Wrong" = "Etwas ist schiefgelaufen"; "Sync Conflicts" = "Sync-Konflikte"; @@ -279,8 +260,6 @@ "Scanning completed in %@" = "Scan in %@ abgeschlossen"; "Scanning started in %@" = "Scan in %@ gestartet"; "Settings" = "Einstellungen"; -"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Teile einen Ordner aus Syncthing auf deinem Desktop – er wird automatisch angenommen."; -"Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Freigabeanfragen werden unten angezeigt, aber Annehmen und Erneut versuchen sind deaktiviert, bis dein Obsidian-Ordner verbunden ist."; "Shared" = "Geteilt"; "Shared With" = "Geteilt mit"; "Shared by an unknown device" = "Geteilt von einem unbekannten Gerät"; @@ -318,8 +297,6 @@ "Terms of Use" = "Nutzungsbedingungen"; "The device will be disconnected and removed from all shared folders." = "Das Gerät wird getrennt und aus allen geteilten Ordnern entfernt."; "The embedded Syncthing bridge did not start for a background sync." = "Die eingebettete Syncthing-Bridge ist für einen Hintergrund-Sync nicht gestartet."; -"The file '%@' was kept as your local version. The other device's version was discarded." = "Die Datei „%@“ wurde als lokale Version behalten. Die Version des anderen Geräts wurde verworfen."; -"The file '%@' was overwritten with the version from the other device." = "Die Datei „%@“ wurde mit der Version des anderen Geräts überschrieben."; "The folder path no longer exists%@." = "Der Ordnerpfad existiert nicht mehr%@."; "The folder scan finished successfully." = "Der Ordnerscan wurde erfolgreich abgeschlossen."; "The sync engine stopped unexpectedly." = "Die Sync-Engine wurde unerwartet beendet."; @@ -359,7 +336,6 @@ "VaultSync does not have the required permission for this action." = "VaultSync hat nicht die erforderliche Berechtigung für diese Aktion."; "VaultSync found a configuration problem." = "VaultSync hat ein Konfigurationsproblem erkannt."; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync benötigt Kamerazugriff, um Syncthing-Geräte-QR-Codes zu scannen. Bitte aktiviere ihn in den Einstellungen."; -"VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync benötigt einmaligen Zugriff auf deinen Obsidian-Ordner, bevor Freigaben angenommen werden können."; "VaultSync reported an unexpected error." = "VaultSync hat einen unerwarteten Fehler gemeldet."; "Wait a moment and retry." = "Warte einen Moment und versuche es erneut."; "What is a conflict?" = "Was ist ein Konflikt?"; @@ -370,8 +346,8 @@ "hours" = "Stunden"; "No Activity Yet" = "Noch keine Aktivität"; "Sync engine running" = "Sync-Engine läuft"; -"onboarding.welcome.title" = "Deine Obsidian-Notizen. Privat synchronisiert."; -"onboarding.welcome.subtitle" = "Halte deinen Vault zwischen deinen eigenen Geräten synchron – ohne fremden Cloud-Speicher."; +"onboarding.welcome.title" = "Deine Obsidian-Einrichtung. Von Grund auf privat."; +"onboarding.welcome.subtitle" = "Verbinde Obsidian und deine eigenen Geräte. Neue geteilte Vault-Angebote können geprüft, aber in dieser Version nicht angenommen werden."; "onboarding.welcome.benefit.private" = "Privat zwischen deinen Geräten"; "onboarding.welcome.benefit.obsidian" = "Für Obsidian Vaults gemacht"; "onboarding.welcome.benefit.noCloud" = "Kein Cloud-Konto erforderlich"; @@ -388,18 +364,14 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync kann nicht auf deinen lokalen Obsidian-Ordner zugreifen."; "Connect your Obsidian folder from the VaultSync home screen." = "Verbinde deinen Obsidian-Ordner auf dem VaultSync-Startbildschirm."; "Computer or server added" = "Computer oder Server hinzugefügt"; -"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Dein erster Sync ist geschafft. Cloud Relay weckt dieses iPhone mit einem Weck-Signal, sobald sich deine Notizen ändern — auch bei geschlossener App."; +"Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes." = "Cloud Relay kann dieses iPhone für Hintergrundprüfungen wecken. Nur-Senden-Vaults können lokale Änderungen hochladen."; "Your iPhone is paired with at least one Syncthing device." = "Dein iPhone ist mit mindestens einem Syncthing-Gerät gekoppelt."; "Your iPhone is not paired with a Syncthing device yet." = "Dein iPhone ist noch nicht mit einem Syncthing-Gerät gekoppelt."; "Add your computer or server from the Devices section on the home screen." = "Füge deinen Computer oder Server im Bereich „Geräte“ auf dem Startbildschirm hinzu."; -"Vault syncing" = "Vault wird synchronisiert"; +"Vault setup" = "Vault-Einrichtung"; "At least one Obsidian vault is active in VaultSync." = "Mindestens ein Obsidian Vault ist in VaultSync aktiv."; -"A vault offer is waiting to be accepted." = "Ein Vault-Angebot wartet auf Annahme."; -"A vault offer is waiting. Accept it from Pending Shares on the home screen." = "Ein Vault-Angebot wartet. Nimm es über „Ausstehende Freigaben“ auf dem Startbildschirm an."; -"A vault offer was seen earlier, but no vault is syncing right now." = "Es wurde bereits ein Vault-Angebot erkannt, aber aktuell wird kein Vault synchronisiert."; -"If syncing has not started, share your Obsidian vault again from Syncthing on your computer." = "Wenn die Synchronisation nicht gestartet ist, teile deinen Obsidian Vault in Syncthing auf deinem Computer erneut."; +"A vault offer was seen earlier, but no vault is configured right now." = "Ein Vault-Angebot wurde bereits erkannt, aber derzeit ist kein Vault konfiguriert."; "No Obsidian vault is active in VaultSync yet." = "In VaultSync ist noch kein Obsidian Vault aktiv."; -"Share your Obsidian vault from Syncthing on your computer." = "Teile deinen Obsidian Vault über Syncthing auf deinem Computer."; "VaultSync’s sync engine is running." = "Die Sync-Engine von VaultSync läuft."; "VaultSync’s sync engine is still starting or unavailable." = "Die Sync-Engine von VaultSync startet noch oder ist derzeit nicht verfügbar."; "If this stays unavailable, restart VaultSync and check the home screen for issues." = "Wenn das so bleibt, starte VaultSync neu und prüfe den Startbildschirm auf Probleme."; @@ -415,12 +387,6 @@ "Skip these on this iPhone? You can change this anytime in Sync Filters." = "Diese auf diesem iPhone überspringen? Du kannst das jederzeit in den Sync-Filtern ändern."; "Skip" = "Überspringen"; "Choose what gets synced to this iPhone" = "Wähle, was auf dieses iPhone synchronisiert wird"; -"Always skip on this iPhone" = "Auf diesem iPhone immer überspringen"; -"More actions" = "Weitere Aktionen"; -"Skipping enabled" = "Überspringen aktiviert"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "„%@“ und seine Konflikt-Kopien werden nicht mehr auf dieses iPhone synchronisiert. Du kannst das in den Sync-Filtern rückgängig machen."; -"1 existing conflict copy was removed." = "1 vorhandene Konflikt-Kopie wurde entfernt."; -"%d existing conflict copies were removed." = "%d vorhandene Konflikt-Kopien wurden entfernt."; "+ conflict copies" = "+ Konflikt-Kopien"; "Could not add filter" = "Filter konnte nicht hinzugefügt werden"; "Could not save filters" = "Filter konnten nicht gespeichert werden"; @@ -450,17 +416,17 @@ "%d Required Devices Are Disconnected" = "%d erforderliche Geräte sind getrennt"; "1 Pending Share Needs Attention" = "1 ausstehende Freigabe benötigt Aufmerksamkeit"; "%d Pending Shares Need Attention" = "%d ausstehende Freigaben benötigen Aufmerksamkeit"; -"1 Conflict Needs Resolution" = "1 Konflikt muss gelöst werden"; -"%d Conflicts Need Resolution" = "%d Konflikte müssen gelöst werden"; +"1 Conflict Available for Review" = "1 Konflikt zur Prüfung verfügbar"; +"%d Conflicts Available for Review" = "%d Konflikte zur Prüfung verfügbar"; /* Issue #10 — conflict notification body */ -"1 file has a sync conflict. Open VaultSync to resolve it." = "1 Datei hat einen Sync-Konflikt. Öffne VaultSync, um ihn zu lösen."; -"%d files have sync conflicts. Open VaultSync to resolve them." = "%d Dateien haben Sync-Konflikte. Öffne VaultSync, um sie zu lösen."; +"1 file has a sync conflict. Open VaultSync to see which copies are still available." = "1 Datei hat einen Sync-Konflikt. Öffne VaultSync, um zu sehen, welche Kopien noch verfügbar sind."; +"%d files have sync conflicts. Open VaultSync to see which copies are still available." = "%d Dateien haben Sync-Konflikte. Öffne VaultSync, um zu sehen, welche Kopien noch verfügbar sind."; /* Issue #10 — conflict-notifications toggle (Settings) */ "Notifications" = "Mitteilungen"; "Conflict Notifications" = "Konflikt-Mitteilungen"; -"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Zeigt ein Banner an, wenn Sync-Konflikte erkannt werden. Das Ausschalten betrifft weder Cloud Relay noch den Hintergrund-Sync – dein Vault synchronisiert weiter."; +"Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads." = "Zeigt ein Banner an, wenn Konfliktkopien erkannt werden. Das Ausschalten hat keine Auswirkungen auf Cloud Relay, Hintergrundprüfungen oder Nur-Senden-Uploads."; /* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ "Alert Banners" = "Hinweis-Banner"; @@ -472,7 +438,7 @@ "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs-Registrierung fehlgeschlagen. Prüfe deine Internetverbindung und wiederhole die Registrierung. Silent Push benötigt keine aktivierten Hinweis-Banner."; /* Issue #10 — background-sync reliability (error-settled outcome) */ -"Background sync settled with at least one folder in an error state." = "Hintergrund-Sync beendet – mindestens ein Ordner ist im Fehlerzustand."; +"Background sync stopped because the engine or a folder reported a safety issue." = "Die Hintergrundsynchronisierung wurde gestoppt, weil die Engine oder ein Ordner ein Sicherheitsproblem gemeldet hat."; /* Issue #10 — relay reachable (vs delivering) */ "Cloud Relay looks reachable" = "Cloud Relay scheint erreichbar"; @@ -508,18 +474,12 @@ "Remove this device?" = "Dieses Gerät entfernen?"; "Double-tap to share this vault with this device." = "Doppeltippen, um diesen Vault mit diesem Gerät zu teilen."; "Double-tap to stop sharing this vault with this device." = "Doppeltippen, um das Teilen dieses Vaults mit diesem Gerät zu beenden."; -"All conflicts resolved" = "Alle Konflikte gelöst"; +"No conflicts found" = "Keine Konflikte gefunden"; "Loading files…" = "Dateien werden geladen…"; "Computing diff…" = "Unterschiede werden berechnet…"; -"Other Device" = "Anderes Gerät"; -"Other Device (%@)" = "Anderes Gerät (%@)"; -"Added lines come from the other device; removed lines are your version on this device." = "Hinzugefügte Zeilen stammen vom anderen Gerät; entfernte Zeilen sind deine Version auf diesem Gerät."; -"(empty or unreadable)" = "(leer oder nicht lesbar)"; -"a new name" = "einen neuen Namen"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Beide Versionen wurden behalten.\n\nDeine lokale Version bleibt als „%@“.\nDie Version des anderen Geräts wurde in „%@“ umbenannt."; "%d conflicts" = "%d Konflikte"; "Your contribution is pending approval." = "Deine Unterstützung wartet auf Freigabe."; -"iOS did not provide a push token required for instant sync." = "iOS hat kein Push-Token bereitgestellt, das für Sofort-Sync erforderlich ist."; +"iOS did not provide a push token required for Cloud Relay wake-ups." = "iOS hat kein Push-Token bereitgestellt, das für Weck-Signale von Cloud Relay erforderlich ist."; "%@ (Trigger: %@)" = "%@ (Auslöser: %@)"; "1 additional file synced in %@" = "1 weitere Datei in %@ synchronisiert"; "%d additional files synced in %@" = "%d weitere Dateien in %@ synchronisiert"; @@ -535,7 +495,6 @@ "No folders were available after forced silent-push restart." = "Nach dem erzwungenen Silent-Push-Neustart waren keine Ordner verfügbar."; "No folders were available for background sync." = "Für den Hintergrund-Sync waren keine Ordner verfügbar."; "No security-scoped bookmark access was available." = "Es war kein Security-Scoped-Bookmark-Zugriff verfügbar."; -"Accept or create a shared vault before relying on background sync." = "Akzeptiere oder erstelle einen geteilten Vault, bevor du dich auf Hintergrund-Sync verlässt."; "Retry from the app and review relay/background diagnostics in Settings." = "Versuche es erneut in der App und prüfe die Relay-/Hintergrund-Diagnose in den Einstellungen."; "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "Silent Push hat Syncthing neu gestartet, aber vor der Rückkehr in den Leerlauf wurde kein echter Sync-Fortschritt beobachtet."; "Sync did not reach idle before %ds deadline." = "Sync hat den Leerlauf nicht vor dem %ds-Zeitlimit erreicht."; @@ -548,12 +507,12 @@ "relay network: %@" = "Relay-Netzwerk: %@"; /* Cloud Relay activation (2026-05-31): honest server-setup + conversion */ -"Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync." = "Cloud Relay ist nicht aktiviert. Ohne Cloud Relay kommen eingehende Änderungen an, wenn du VaultSync öffnest."; -"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Aktiviere Cloud Relay im Relay-Tab, wenn Änderungen im selben Moment gepusht werden sollen."; +"Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies." = "Cloud Relay ist nicht aktiviert. Öffne VaultSync, um den aktuellen Status und Konfliktkopien zu prüfen."; +"Enable Cloud Relay on the Relay tab to wake VaultSync for background checks." = "Aktiviere Cloud Relay im Relay-Tab, um VaultSync für Hintergrundprüfungen zu wecken."; "Cloud Relay — finish server setup" = "Cloud Relay – Server-Einrichtung abschließen"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "Du hast ein Abo, aber es ist noch kein aktuelles Weck-Signal angekommen. Stelle sicher, dass der Helfer „vaultsync-notify“ auf deinem Server läuft."; "Set up the server helper from the Relay tab → Set Up Your Server." = "Richte den Server-Helfer unter Relay-Tab → „Server einrichten“ ein."; -"Wake-ups are being delivered — incoming changes sync the moment they happen." = "Weck-Signale werden zugestellt – eingehende Änderungen synchronisieren im selben Moment."; +"Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes." = "Weck-Signale werden zugestellt. VaultSync kann den Status im Hintergrund prüfen und Nur-Senden-Vaults können lokale Änderungen hochladen."; "Your server helper is running — wake-ups are being delivered." = "Dein Server-Helfer läuft – Weck-Signale werden zugestellt."; "Why this step" = "Warum dieser Schritt"; "Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay braucht einen kleinen Helfer auf deinem Server. Er beobachtet Syncthing auf Änderungen und sendet VaultSync ein Weck-Signal – deine Notizen sieht er nie. Ohne ihn hat das Abo nichts, womit es die App wecken könnte."; @@ -570,7 +529,7 @@ "Last wake-up" = "Letztes Weck-Signal"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Verlängert sich automatisch bis zur Kündigung. Jederzeit kündbar unter Einstellungen → Abonnements."; "Cancel anytime in Settings → Subscriptions" = "Jederzeit kündbar unter Einstellungen → Abonnements"; -"Get instant updates" = "Sofortige Updates erhalten"; +"Enable background wake-ups" = "Weck-Signale im Hintergrund aktivieren"; "Turn on Cloud Relay" = "Cloud Relay aktivieren"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ @@ -581,35 +540,28 @@ "Add your server first" = "Füge zuerst deinen Server hinzu"; "Best value" = "Bestes Angebot"; "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe." = "Cloud Relay weckt ein bestimmtes Gerät. Verbinde im Tab „Geräte“ den Computer oder Server, der deinen Vault bereitstellt, und komm dann zum Abonnieren zurück."; -"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Erledige diese Schritte direkt hier. Sie leuchten grün auf, während du vorankommst – und du kannst sie jederzeit später vom Startbildschirm aus abschließen."; "Connect your Obsidian folder" = "Deinen Obsidian-Ordner verbinden"; "Double tap to copy" = "Zum Kopieren doppeltippen"; -"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Gib VaultSync einmaligen Zugriff auf deinen lokalen Obsidian-Ordner, damit es deine Notizen synchronisieren kann."; "How is this private?" = "Wie ist das privat?"; -"Instant sync, still private" = "Sofort synchron, weiterhin privat"; -"Let’s get your vault synced" = "Bringen wir deinen Vault zum Synchronisieren"; +"Private background wake-ups" = "Private Weck-Signale im Hintergrund"; "Loading plans…" = "Tarife werden geladen …"; "Manage" = "Verwalten"; "Monthly" = "Monatlich"; "One step left to activate" = "Noch ein Schritt bis zur Aktivierung"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Optional: Aktiviere Cloud Relay später für sofortige Updates – du findest es im Relay-Tab."; +"Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab." = "Optional: Aktiviere Cloud Relay später für Weck-Signale im Hintergrund – du findest es im Relay-Tab."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Verbinde dieses iPhone per Geräte-ID oder QR-Code mit dem Syncthing-Gerät, das deinen Vault bereitstellt."; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay-Status & Diagnose"; -"One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes." = "Noch ein Schritt: Führe eine einzige Zeile auf deinem Server aus, und sofortige Updates starten. Der Helfer sendet nur ein Weck-Signal – deine Notizen sieht er nie."; +"One step left: run one command on your server to enable background wake-ups. The helper never sees your notes." = "Noch ein Schritt: Führe einen Befehl auf deinem Server aus, um Weck-Signale im Hintergrund zu aktivieren. Der Helfer sieht deine Notizen nie."; "Save %d%%" = "%d %% sparen"; "Server helper setup" = "Server-Helfer-Einrichtung"; "Set up the server helper" = "Server-Helfer einrichten"; -"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Teile deinen Obsidian-Vault über Syncthing auf deinem Computer. VaultSync nimmt ihn automatisch an – das wird grün, sobald er ankommt."; "Starts a subscription purchase." = "Startet einen Abo-Kauf."; "Sync" = "Sync"; -"Sync your first vault" = "Ersten Vault synchronisieren"; -"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Weck-Signale werden zugestellt – Änderungen von deinen anderen Geräten kommen sofort an."; "Yearly" = "Jährlich"; -"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Deine lokale Version bleibt erhalten, und die Version des anderen Geräts wird unter einem neuen Namen hinzugefügt. Es wird nichts verworfen."; -"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Deine Notizen erreichen nie unsere Server. Cloud Relay sendet nur ein winziges Weck-Signal, damit Änderungen von deinen anderen Geräten im selben Moment ankommen – sogar bei geschlossener App."; +"Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes." = "Deine Notizen erreichen nie unsere Server. Cloud Relay sendet nur ein Weck-Signal, damit VaultSync den Status im Hintergrund prüfen kann. Nur-Senden-Vaults können lokale Änderungen hochladen."; "One-step setup: a single line on your server." = "Einrichtung in einem Schritt: eine einzige Zeile auf deinem Server."; -"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Dein Vault synchronisiert bereits kostenlos und Peer-to-Peer. Relay beseitigt nur die Wartezeit „zum Synchronisieren die App öffnen“ – es ist kein Cloud-Speicher."; +"VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes." = "VaultSync bleibt Peer-to-Peer. Relay stellt Weck-Signale im Hintergrund bereit; es ist kein Cloud-Speicher und überträgt niemals deine Notizen."; "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "Du hast abonniert, aber es ist noch kein Weck-Signal angekommen. Cloud Relay liefert erst, wenn der Helfer auf deinem Server läuft."; /* Folder path resilience (#25) */ @@ -629,7 +581,6 @@ "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "Du hast ein Abo, aber dein Server hat dieses iPhone noch nie geweckt. Ein Schritt schließt die Einrichtung ab."; "Opens Cloud Relay setup." = "Öffnet die Cloud-Relay-Einrichtung."; "Cloud Relay went quiet" = "Cloud Relay ist verstummt"; -"Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "Dein Server hat dieses iPhone gerade erreicht. Eingehende Änderungen synchronisieren jetzt im selben Moment."; "Great" = "Super"; "Not active yet" = "Noch nicht aktiv"; "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "Du hast ein Abo. Weck-Signale beginnen, sobald der Helfer auf dem dauerhaft laufenden Computer oder Server läuft – schließe die Einrichtung unten ab."; @@ -646,7 +597,7 @@ /* Manual conflict review — notes and .obsidian state (Settings) */ "Review Conflicts Manually" = "Konflikte manuell prüfen"; -"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep." = "VaultSync wählt nicht automatisch zwischen Konfliktkopien in deinen Notizen, Obsidian-Einstellungen oder Plugin-Daten. Prüfe jeden Konflikt und entscheide, was du behalten möchtest."; +"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version." = "VaultSync wählt nicht automatisch zwischen Konfliktkopien in deinen Notizen, Obsidian-Einstellungen oder Plugin-Daten. Du kannst jeden Konflikt prüfen, aber Wiederherstellungsaktionen sind in dieser Version nicht verfügbar."; /* Path collision migration shield (#45) — vaults an older version already merged onto one folder */ "Two Vaults Are Sharing One Folder" = "Zwei Vaults teilen sich einen Ordner"; @@ -667,9 +618,7 @@ "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "Der Ordner „%@“ enthält bereits Dateien. Die Freigabe anzunehmen würde seinen Inhalt mit dem geteilten Vault zusammenführen und das Ergebnis mit den anderen Geräten synchronisieren."; "Vault Folder Was Moved or Deleted" = "Vault-Ordner wurde verschoben oder gelöscht"; "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync kann nicht mehr überprüfen, ob dieser Ordner noch die Daten dieses Vaults enthält%@ — der Ordner wurde vermutlich außerhalb von VaultSync verschoben, umbenannt, ersetzt oder gelöscht. Die Synchronisierung wurde gestoppt, um deine Notizen zu schützen."; -"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "Hast du den Ordner verschoben oder umbenannt, verschiebe ihn an seinen ursprünglichen Ort zurück. Ist er nicht mehr vorhanden, entferne diesen Vault auf diesem iPhone und nimm ihn unter „Ausstehende Freigaben“ erneut an. VaultSync verschiebt, erstellt oder löscht Ordner nie von selbst."; "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Folge den Wiederherstellungsschritten beim betroffenen Vault — ein erneuter Scan kann einen verschobenen oder gelöschten Vault-Ordner nicht reparieren."; -"Offer “%@” received — accepting…" = "Angebot „%@“ erhalten — wird angenommen …"; "Offer “%@” received — connect your Obsidian folder first." = "Angebot „%@“ erhalten — verbinde zuerst deinen Obsidian-Ordner."; "Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen." = "Das Angebot „%@“ braucht deine Entscheidung. Tippe unten auf „Einrichtung später abschließen“, um es auf dem Startbildschirm zu prüfen."; @@ -688,10 +637,8 @@ "Waiting for first sync" = "Wartet auf die erste Synchronisierung"; /* Onboarding guidance polish (#95) */ -"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing." = "Gerät hinzugefügt. Bestätige dieses iPhone jetzt in Syncthing auf deinem Computer — dort erscheint eine Bestätigungsanfrage. Teile danach deinen Vault, um die Synchronisierung zu starten."; "The folder you selected is stored in iCloud Drive. iCloud can keep files as placeholders that are not fully downloaded on this iPhone, which can stall syncing and create conflicts. For reliable syncing, use your vaults under \"On My iPhone\" → \"Obsidian\" and select that folder instead." = "Der ausgewählte Ordner liegt in iCloud Drive. iCloud kann Dateien als Platzhalter behalten, die auf diesem iPhone nicht vollständig geladen sind — das kann die Synchronisierung anhalten und Konflikte erzeugen. Lege deine Vaults für verlässliches Synchronisieren unter „Auf meinem iPhone“ → „Obsidian“ ab und wähle stattdessen diesen Ordner aus."; "Opens the setup checklist." = "Öffnet die Einrichtungs-Checkliste."; -"A vault offer was ignored on this iPhone, so it is not accepted automatically." = "Ein Vault-Angebot wurde auf diesem iPhone ignoriert und wird deshalb nicht automatisch angenommen."; "Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer." = "Öffne „Ignorierte Freigaben“ unter den ausstehenden Freigaben auf dem Startbildschirm und tippe auf „Freigabe wiederherstellen“. Erneutes Teilen vom Computer erzeugt kein neues Angebot."; "Camera Unavailable" = "Kamera nicht verfügbar"; "The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID." = "Die Kamera konnte auf diesem Gerät nicht gestartet werden. Gib die Geräte-ID stattdessen manuell ein — in Syncthing auf deinem Computer unter Aktionen → ID anzeigen."; @@ -940,3 +887,52 @@ "Upload check rate limited — upload unobserved" = "Upload-Prüfung ratenbegrenzt — Upload unbeobachtet"; "Upload check unsupported for this exact folder and peer" = "Upload-Prüfung für genau diesen Ordner und Peer nicht unterstützt"; "Upload capability unavailable — no upload evidence" = "Upload-Capability nicht verfügbar — keine Upload-Evidence"; +"Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review." = "Upload- und Download-Prüfungen sind nicht verfügbar, solange empfangsseitige Änderungen deaktiviert sind. Kopplungsdetails bleiben zur Überprüfung verfügbar."; +"Conflict Safety Review Required" = "Konfliktsicherheitsprüfung erforderlich"; +"Conflict Safety Status Unavailable" = "Konfliktsicherheitsstatus nicht verfügbar"; +"Background Sync Stopped for Safety" = "Hintergrundsynchronisierung aus Sicherheitsgründen gestoppt"; +"Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable." = "Öffne VaultSync, um das Sicherheitsproblem zu prüfen. Lass Konfliktkopien unverändert, solange keine sichere Wiederherstellung verfügbar ist."; +"Conflict Safety Stop" = "Konfliktsicherheitsstopp"; +"Checking Conflict Safety" = "Konfliktsicherheit wird geprüft"; +"Conflict Recovery Unavailable" = "Konfliktwiederherstellung nicht verfügbar"; +"Conflict recovery actions are not available in this version." = "Konfliktwiederherstellungsaktionen sind in dieser Version nicht verfügbar."; +"Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version." = "Prüfe hier die noch verfügbaren Kopien. Lass die Dateien unverändert; VaultSync kann in dieser Version keine Wiederherstellungsaktion ausführen."; +"Conflict Details" = "Konfliktdetails"; +"Review Conflicts" = "Konflikte prüfen"; +"VaultSync keeps receive-capable vaults read-only in this version." = "VaultSync hält Vaults mit Empfangsfunktion in dieser Version schreibgeschützt."; +"You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable." = "Du kannst verfügbare Statusinformationen und Konfliktkopien prüfen, aber empfangsseitige Änderungen und die Konfliktwiederherstellung sind nicht verfügbar."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own." = "Wenn du den Ordner verschoben oder umbenannt hast, verschiebe ihn an seinen ursprünglichen Ort zurück. Wenn er nicht mehr vorhanden ist, lass diesen Vault gestoppt und bewahre alle verbleibenden Kopien auf. Das Annehmen neuer Freigaben ist in dieser Version nicht verfügbar. VaultSync verschiebt, erstellt oder löscht niemals selbstständig Ordner."; +"Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version." = "Lass die betroffenen Vaults pausiert und bewahre jede verbleibende Kopie auf. Das Annehmen neuer Freigaben ist in dieser Version nicht verfügbar."; +"A vault offer is available for inspection." = "Ein Vault-Angebot ist zur Prüfung verfügbar."; +"Open Pending Shares to inspect the offer details. This version cannot accept it." = "Öffne die ausstehenden Freigaben, um die Details des Angebots zu prüfen. Diese Version kann es nicht annehmen."; +"An ignored vault offer remains stored on this iPhone." = "Ein ignoriertes Vault-Angebot bleibt auf diesem iPhone gespeichert."; +"Open Pending Shares to inspect its details. No action is available in this version." = "Öffne die ausstehenden Freigaben, um die Details zu prüfen. In dieser Version ist keine Aktion verfügbar."; +"New share acceptance is unavailable in this version." = "Das Annehmen neuer Freigaben ist in dieser Version nicht verfügbar."; +"New shared vault offers can be inspected, but not accepted in this version." = "Neue geteilte Vault-Angebote können geprüft, aber in dieser Version nicht angenommen werden."; +"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version." = "Gerät hinzugefügt. Bestätige dieses iPhone jetzt in Syncthing auf deinem Computer – dort erscheint eine Bestätigungsanfrage. Neue geteilte Vault-Angebote können in dieser Version nur geprüft werden."; +"VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status." = "VaultSync benötigt einmalig Zugriff auf deinen Obsidian-Ordner, um lokale Vaults und den bestehenden Synchronisierungsstatus zu prüfen."; +"Not syncing in this version — new share offers are inspection-only." = "In dieser Version nicht synchronisiert – neue Freigabeangebote können nur geprüft werden."; +"Set up VaultSync" = "VaultSync einrichten"; +"Connect Obsidian and a device here. New shared vault offers are inspection-only in this version." = "Verbinde hier Obsidian und ein Gerät. Neue geteilte Vault-Angebote können in dieser Version nur geprüft werden."; +"Offer “%@” is available for inspection only. This version cannot accept it." = "Das Angebot „%@“ kann nur geprüft werden. Diese Version kann es nicht annehmen."; +"Pending shares are read-only in this version." = "Ausstehende Freigaben sind in dieser Version schreibgeschützt."; +"You can inspect who shared each offer, but no action is available." = "Du kannst prüfen, von wem jedes Angebot stammt, aber es ist keine Aktion verfügbar."; +"Read Only" = "Schreibgeschützt"; +"No Vaults Syncing" = "Keine Vaults werden synchronisiert"; +"Conflict Inspection Unavailable" = "Konfliktprüfung nicht verfügbar"; +"VaultSync cannot verify whether the conflict list is complete." = "VaultSync kann nicht überprüfen, ob die Konfliktliste vollständig ist."; +"Open conflicts to review any previously visible copies. No recovery action is available." = "Öffne die Konflikte, um zuvor sichtbare Kopien zu prüfen. Wiederherstellungsaktionen sind nicht verfügbar."; +"Current File" = "Aktuelle Datei"; +"Conflict Copy" = "Konfliktkopie"; +"Added lines are from the conflict copy; removed lines are from the current file." = "Hinzugefügte Zeilen stammen aus der Konfliktkopie; entfernte Zeilen aus der aktuellen Datei."; +"(empty)" = "(leer)"; +"This copy is unavailable for inspection." = "Diese Kopie kann nicht geprüft werden."; +"Conflict inspection is unavailable." = "Die Konfliktprüfung ist nicht verfügbar."; +"VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review." = "VaultSync kann nicht überprüfen, ob die Konfliktliste vollständig ist. Zuvor sichtbare Kopien werden weiterhin zur Prüfung angezeigt."; +"Vault Folder Needs Manual Recovery" = "Vault-Ordner muss manuell wiederhergestellt werden"; +"VaultSync can no longer verify the configured vault folder." = "VaultSync kann den konfigurierten Vault-Ordner nicht mehr verifizieren."; +"Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically." = "Lass diesen Vault angehalten und bewahre jede verbleibende Kopie auf. Stelle den ursprünglichen Ordner am ursprünglichen Ort wieder her; VaultSync verschiebt ihn nicht und weist ihm nicht automatisch einen anderen Ort zu."; +"Vault configured" = "Vault konfiguriert"; +"At least one Send Only vault can continue uploading local changes." = "Mindestens ein Nur-Senden-Vault kann lokale Änderungen weiterhin hochladen."; +"Existing receive-capable vaults are available for review only in this version." = "Bestehende Vaults mit Empfangsfunktion können in dieser Version nur geprüft werden."; +"Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version." = "Öffne VaultSync, um die Geräte- und Ordnerkonfiguration zu prüfen. Die Annahme neuer Freigaben ist in dieser Version nicht verfügbar."; diff --git a/ios/VaultSync/en.lproj/InfoPlist.strings b/ios/VaultSync/en.lproj/InfoPlist.strings index 123755a..f98f62a 100644 --- a/ios/VaultSync/en.lproj/InfoPlist.strings +++ b/ios/VaultSync/en.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; "NSCameraUsageDescription" = "VaultSync uses the camera to scan setup and pairing QR codes you explicitly choose."; -"NSLocalNetworkUsageDescription" = "VaultSync connects directly to your other devices on the same network so your vaults sync instantly, without a detour through the internet."; +"NSLocalNetworkUsageDescription" = "VaultSync uses the local network to connect directly to your other devices for status checks and Send Only uploads."; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 5e48ab4..7385aea 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -1,8 +1,4 @@ "About" = "About"; -"Accept First Pending Share" = "Accept First Pending Share"; -"Accept Pending Share" = "Accept Pending Share"; -"Accept Share" = "Accept Share"; -"Accept a share to activate syncing for that vault." = "Accept a share to activate syncing for that vault."; "Action Needed" = "Action Needed"; "Actions" = "Actions"; "Active" = "Active"; @@ -45,11 +41,9 @@ "Completion" = "Completion"; "Configuration Error" = "Configuration Error"; "Conflict Resolution Failed" = "Conflict Resolution Failed"; -"Conflict Resolved" = "Conflict Resolved"; "Conflicted Files" = "Conflicted Files"; "Conflicts" = "Conflicts"; -"Conflicts mean multiple versions exist and need a manual decision." = "Conflicts mean multiple versions exist and need a manual decision."; -"Connect Obsidian to accept shares" = "Connect Obsidian to accept shares"; +"A separate conflict copy was detected for a file." = "A separate conflict copy was detected for a file."; "Connect Obsidian Folder" = "Connect Obsidian Folder"; "Connect to Obsidian first" = "Connect to Obsidian first"; "Connected" = "Connected"; @@ -105,23 +99,16 @@ "Healthy" = "Healthy"; "How to fix: %@" = "How to fix: %@"; "How to share from your computer" = "How to share from your computer"; -"If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep." = "If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep."; +"If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version." = "If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version."; "Ignore for Now" = "Ignore for Now"; "Ignored shares (%d)" = "Ignored shares (%d)"; "In progress" = "In progress"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it."; "Invalid Input" = "Invalid Input"; "Invalid folder name: '%@'" = "Invalid folder name: '%@'"; -"Keep Both" = "Keep Both"; "Keep Both did not change any files because the new copy name is already in use." = "Keep Both did not change any files because the new copy name is already in use."; "Keep Both did not change any files because this storage location does not support safe renaming." = "Keep Both did not change any files because this storage location does not support safe renaming."; -"Keep Other" = "Keep Other"; -"Keep Other Device's Version" = "Keep Other Device's Version"; -"Keep This" = "Keep This"; -"Keep This Device's Version" = "Keep This Device's Version"; -"Keep both versions" = "Keep both versions"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Keep the app open for a moment and retry. If this persists, restart VaultSync."; -"Keeps your local file and renames the other device's file." = "Keeps your local file and renames the other device's file."; "Last Check" = "Last Check"; "Last Failure" = "Last Failure"; "Last Relay Error" = "Last Relay Error"; @@ -179,7 +166,7 @@ "Open VaultSync" = "Open VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Open VaultSync once to restart Syncthing, then retry."; "Open VaultSync to allow a longer foreground sync session." = "Open VaultSync to allow a longer foreground sync session."; -"Open conflicts and choose which version to keep." = "Open conflicts and choose which version to keep."; +"Open conflicts to see which copies are still available. Recovery actions are unavailable." = "Open conflicts to see which copies are still available. Recovery actions are unavailable."; "Open full relay troubleshooting" = "Open full relay troubleshooting"; "Open iOS Notification Settings" = "Open iOS Notification Settings"; "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry."; @@ -187,11 +174,9 @@ "Opens discovery, relay, and notification settings." = "Opens discovery, relay, and notification settings."; "Opens the form to add a Syncthing device." = "Opens the form to add a Syncthing device."; "Optional" = "Optional"; -"Overwrites your local file with the version from the other device." = "Overwrites your local file with the version from the other device."; "Path" = "Path"; "Peer connection is active." = "Peer connection is active."; "Pending Shares" = "Pending Shares"; -"Pending shares are waiting to be accepted before sync can start." = "Pending shares are waiting to be accepted before sync can start."; "Per-Device Provisioning" = "Per-Device Provisioning"; "Permission Required" = "Permission Required"; "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"."; @@ -206,7 +191,7 @@ "Reconnect Obsidian Folder" = "Reconnect Obsidian Folder"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "Reconnect Obsidian access or adjust folder permissions on the host device."; "Reconnect devices or add missing peers to restore continuous sync." = "Reconnect devices or add missing peers to restore continuous sync."; -"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan."; +"Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version." = "Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version."; "Recreate or reselect the folder, then trigger a rescan." = "Recreate or reselect the folder, then trigger a rescan."; "Registered" = "Registered"; "Relay Backend" = "Relay Backend"; @@ -235,19 +220,16 @@ "Remove and re-share the folder from your desktop device." = "Remove and re-share the folder from your desktop device."; "Removed line. %@" = "Removed line. %@"; "Rename Failed" = "Rename Failed"; -"Rename the existing copy in Files, then try Keep Both again." = "Rename the existing copy in Files, then try Keep Both again."; -"Resolve this conflict manually in Files without replacing either file." = "Resolve this conflict manually in Files without replacing either file."; +"Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available." = "Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available."; "Renews" = "Renews"; "Requesting camera access…" = "Requesting camera access…"; "Rescan Failed" = "Rescan Failed"; "Rescan Failed Vaults" = "Rescan Failed Vaults"; "Rescan Vault" = "Rescan Vault"; "Rescan failed vaults, then verify folder access and permissions." = "Rescan failed vaults, then verify folder access and permissions."; -"Resolve Conflict" = "Resolve Conflict"; -"Resolve Conflicts" = "Resolve Conflicts"; "Restore Purchases" = "Restore Purchases"; "Restore Share" = "Restore Share"; -"A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss."; +"A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available." = "A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available."; "APNs Registration" = "APNs Registration"; "APNs Token" = "APNs Token"; "Context: %@ · %@" = "Context: %@ · %@"; @@ -256,7 +238,6 @@ "Present" = "Present"; "Purchase Failed" = "Purchase Failed"; "Rescan All Vaults" = "Rescan All Vaults"; -"Review and Accept" = "Review and Accept"; "Selects this plan." = "Selects this plan."; "Something Went Wrong" = "Something Went Wrong"; "Sync Conflicts" = "Sync Conflicts"; @@ -279,8 +260,6 @@ "Scanning completed in %@" = "Scanning completed in %@"; "Scanning started in %@" = "Scanning started in %@"; "Settings" = "Settings"; -"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Share a folder from your desktop Syncthing — it will be accepted automatically."; -"Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected."; "Shared" = "Shared"; "Shared With" = "Shared With"; "Shared by an unknown device" = "Shared by an unknown device"; @@ -318,8 +297,6 @@ "Terms of Use" = "Terms of Use"; "The device will be disconnected and removed from all shared folders." = "The device will be disconnected and removed from all shared folders."; "The embedded Syncthing bridge did not start for a background sync." = "The embedded Syncthing bridge did not start for a background sync."; -"The file '%@' was kept as your local version. The other device's version was discarded." = "The file '%@' was kept as your local version. The other device's version was discarded."; -"The file '%@' was overwritten with the version from the other device." = "The file '%@' was overwritten with the version from the other device."; "The folder path no longer exists%@." = "The folder path no longer exists%@."; "The folder scan finished successfully." = "The folder scan finished successfully."; "The sync engine stopped unexpectedly." = "The sync engine stopped unexpectedly."; @@ -359,7 +336,6 @@ "VaultSync does not have the required permission for this action." = "VaultSync does not have the required permission for this action."; "VaultSync found a configuration problem." = "VaultSync found a configuration problem."; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings."; -"VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync needs one-time access to your Obsidian folder before it can accept shares."; "VaultSync reported an unexpected error." = "VaultSync reported an unexpected error."; "Wait a moment and retry." = "Wait a moment and retry."; "What is a conflict?" = "What is a conflict?"; @@ -370,8 +346,8 @@ "hours" = "hours"; "No Activity Yet" = "No Activity Yet"; "Sync engine running" = "Sync engine running"; -"onboarding.welcome.title" = "Your Obsidian notes. Privately synced."; -"onboarding.welcome.subtitle" = "Keep your vault in sync between your own devices — without relying on third-party cloud storage."; +"onboarding.welcome.title" = "Your Obsidian setup. Private by design."; +"onboarding.welcome.subtitle" = "Connect Obsidian and your own devices. New shared vault offers can be inspected, but not accepted in this version."; "onboarding.welcome.benefit.private" = "Private between your devices"; "onboarding.welcome.benefit.obsidian" = "Built for Obsidian vaults"; "onboarding.welcome.benefit.noCloud" = "No cloud account required"; @@ -388,18 +364,14 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync cannot access your local Obsidian folder."; "Connect your Obsidian folder from the VaultSync home screen." = "Connect your Obsidian folder from the VaultSync home screen."; "Computer or server added" = "Computer or server added"; -"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed."; +"Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes." = "Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes."; "Your iPhone is paired with at least one Syncthing device." = "Your iPhone is paired with at least one Syncthing device."; "Your iPhone is not paired with a Syncthing device yet." = "Your iPhone is not paired with a Syncthing device yet."; "Add your computer or server from the Devices section on the home screen." = "Add your computer or server from the Devices section on the home screen."; -"Vault syncing" = "Vault syncing"; +"Vault setup" = "Vault setup"; "At least one Obsidian vault is active in VaultSync." = "At least one Obsidian vault is active in VaultSync."; -"A vault offer is waiting to be accepted." = "A vault offer is waiting to be accepted."; -"A vault offer is waiting. Accept it from Pending Shares on the home screen." = "A vault offer is waiting. Accept it from Pending Shares on the home screen."; -"A vault offer was seen earlier, but no vault is syncing right now." = "A vault offer was seen earlier, but no vault is syncing right now."; -"If syncing has not started, share your Obsidian vault again from Syncthing on your computer." = "If syncing has not started, share your Obsidian vault again from Syncthing on your computer."; +"A vault offer was seen earlier, but no vault is configured right now." = "A vault offer was seen earlier, but no vault is configured right now."; "No Obsidian vault is active in VaultSync yet." = "No Obsidian vault is active in VaultSync yet."; -"Share your Obsidian vault from Syncthing on your computer." = "Share your Obsidian vault from Syncthing on your computer."; "VaultSync’s sync engine is running." = "VaultSync’s sync engine is running."; "VaultSync’s sync engine is still starting or unavailable." = "VaultSync’s sync engine is still starting or unavailable."; "If this stays unavailable, restart VaultSync and check the home screen for issues." = "If this stays unavailable, restart VaultSync and check the home screen for issues."; @@ -415,12 +387,6 @@ "Skip these on this iPhone? You can change this anytime in Sync Filters." = "Skip these on this iPhone? You can change this anytime in Sync Filters."; "Skip" = "Skip"; "Choose what gets synced to this iPhone" = "Choose what gets synced to this iPhone"; -"Always skip on this iPhone" = "Always skip on this iPhone"; -"More actions" = "More actions"; -"Skipping enabled" = "Skipping enabled"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters."; -"1 existing conflict copy was removed." = "1 existing conflict copy was removed."; -"%d existing conflict copies were removed." = "%d existing conflict copies were removed."; "+ conflict copies" = "+ conflict copies"; "Could not add filter" = "Could not add filter"; "Could not save filters" = "Could not save filters"; @@ -450,17 +416,17 @@ "%d Required Devices Are Disconnected" = "%d Required Devices Are Disconnected"; "1 Pending Share Needs Attention" = "1 Pending Share Needs Attention"; "%d Pending Shares Need Attention" = "%d Pending Shares Need Attention"; -"1 Conflict Needs Resolution" = "1 Conflict Needs Resolution"; -"%d Conflicts Need Resolution" = "%d Conflicts Need Resolution"; +"1 Conflict Available for Review" = "1 Conflict Available for Review"; +"%d Conflicts Available for Review" = "%d Conflicts Available for Review"; /* Issue #10 — conflict notification body */ -"1 file has a sync conflict. Open VaultSync to resolve it." = "1 file has a sync conflict. Open VaultSync to resolve it."; -"%d files have sync conflicts. Open VaultSync to resolve them." = "%d files have sync conflicts. Open VaultSync to resolve them."; +"1 file has a sync conflict. Open VaultSync to see which copies are still available." = "1 file has a sync conflict. Open VaultSync to see which copies are still available."; +"%d files have sync conflicts. Open VaultSync to see which copies are still available." = "%d files have sync conflicts. Open VaultSync to see which copies are still available."; /* Issue #10 — conflict-notifications toggle (Settings) */ "Notifications" = "Notifications"; "Conflict Notifications" = "Conflict Notifications"; -"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing."; +"Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads." = "Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads."; /* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ "Alert Banners" = "Alert Banners"; @@ -472,7 +438,7 @@ "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled."; /* Issue #10 — background-sync reliability (error-settled outcome) */ -"Background sync settled with at least one folder in an error state." = "Background sync settled with at least one folder in an error state."; +"Background sync stopped because the engine or a folder reported a safety issue." = "Background sync stopped because the engine or a folder reported a safety issue."; /* Issue #10 — relay reachable (vs delivering) */ "Cloud Relay looks reachable" = "Cloud Relay looks reachable"; @@ -508,18 +474,12 @@ "Remove this device?" = "Remove this device?"; "Double-tap to share this vault with this device." = "Double-tap to share this vault with this device."; "Double-tap to stop sharing this vault with this device." = "Double-tap to stop sharing this vault with this device."; -"All conflicts resolved" = "All conflicts resolved"; +"No conflicts found" = "No conflicts found"; "Loading files…" = "Loading files…"; "Computing diff…" = "Computing diff…"; -"Other Device" = "Other Device"; -"Other Device (%@)" = "Other Device (%@)"; -"Added lines come from the other device; removed lines are your version on this device." = "Added lines come from the other device; removed lines are your version on this device."; -"(empty or unreadable)" = "(empty or unreadable)"; -"a new name" = "a new name"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'."; "%d conflicts" = "%d conflicts"; "Your contribution is pending approval." = "Your contribution is pending approval."; -"iOS did not provide a push token required for instant sync." = "iOS did not provide a push token required for instant sync."; +"iOS did not provide a push token required for Cloud Relay wake-ups." = "iOS did not provide a push token required for Cloud Relay wake-ups."; "%@ (Trigger: %@)" = "%@ (Trigger: %@)"; "1 additional file synced in %@" = "1 additional file synced in %@"; "%d additional files synced in %@" = "%d additional files synced in %@"; @@ -535,7 +495,6 @@ "No folders were available after forced silent-push restart." = "No folders were available after forced silent-push restart."; "No folders were available for background sync." = "No folders were available for background sync."; "No security-scoped bookmark access was available." = "No security-scoped bookmark access was available."; -"Accept or create a shared vault before relying on background sync." = "Accept or create a shared vault before relying on background sync."; "Retry from the app and review relay/background diagnostics in Settings." = "Retry from the app and review relay/background diagnostics in Settings."; "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle."; "Sync did not reach idle before %ds deadline." = "Sync did not reach idle before %ds deadline."; @@ -548,12 +507,12 @@ "relay network: %@" = "relay network: %@"; /* Cloud Relay activation (2026-05-31): honest server-setup + conversion */ -"Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync." = "Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync."; -"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen."; +"Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies." = "Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies."; +"Enable Cloud Relay on the Relay tab to wake VaultSync for background checks." = "Enable Cloud Relay on the Relay tab to wake VaultSync for background checks."; "Cloud Relay — finish server setup" = "Cloud Relay — finish server setup"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server."; "Set up the server helper from the Relay tab → Set Up Your Server." = "Set up the server helper from the Relay tab → Set Up Your Server."; -"Wake-ups are being delivered — incoming changes sync the moment they happen." = "Wake-ups are being delivered — incoming changes sync the moment they happen."; +"Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes." = "Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes."; "Your server helper is running — wake-ups are being delivered." = "Your server helper is running — wake-ups are being delivered."; "Why this step" = "Why this step"; "Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with."; @@ -570,7 +529,7 @@ "Last wake-up" = "Last wake-up"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions."; "Cancel anytime in Settings → Subscriptions" = "Cancel anytime in Settings → Subscriptions"; -"Get instant updates" = "Get instant updates"; +"Enable background wake-ups" = "Enable background wake-ups"; "Turn on Cloud Relay" = "Turn on Cloud Relay"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ @@ -581,35 +540,28 @@ "Add your server first" = "Add your server first"; "Best value" = "Best value"; "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe." = "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe."; -"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen."; "Connect your Obsidian folder" = "Connect your Obsidian folder"; "Double tap to copy" = "Double tap to copy"; -"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Give VaultSync one-time access to your local Obsidian folder so it can sync your notes."; "How is this private?" = "How is this private?"; -"Instant sync, still private" = "Instant sync, still private"; -"Let’s get your vault synced" = "Let’s get your vault synced"; +"Private background wake-ups" = "Private background wake-ups"; "Loading plans…" = "Loading plans…"; "Manage" = "Manage"; "Monthly" = "Monthly"; "One step left to activate" = "One step left to activate"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab."; +"Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab." = "Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code."; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay health & diagnostics"; -"One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes." = "One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes."; +"One step left: run one command on your server to enable background wake-ups. The helper never sees your notes." = "One step left: run one command on your server to enable background wake-ups. The helper never sees your notes."; "Save %d%%" = "Save %d%%"; "Server helper setup" = "Server helper setup"; "Set up the server helper" = "Set up the server helper"; -"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives."; "Starts a subscription purchase." = "Starts a subscription purchase."; "Sync" = "Sync"; -"Sync your first vault" = "Sync your first vault"; -"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Wake-ups are being delivered — changes from your other devices arrive instantly."; "Yearly" = "Yearly"; -"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded."; -"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed."; +"Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes." = "Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes."; "One-step setup: a single line on your server." = "One-step setup: a single line on your server."; -"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage."; +"VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes." = "VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes."; "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server."; /* Folder path resilience (#25) */ @@ -629,7 +581,6 @@ "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "You’re subscribed, but your server has never woken this iPhone. One step finishes setup."; "Opens Cloud Relay setup." = "Opens Cloud Relay setup."; "Cloud Relay went quiet" = "Cloud Relay went quiet"; -"Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "Your server just reached this iPhone. Incoming changes now sync the moment they happen."; "Great" = "Great"; "Not active yet" = "Not active yet"; "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below."; @@ -646,7 +597,7 @@ /* Manual conflict review — notes and .obsidian state (Settings) */ "Review Conflicts Manually" = "Review Conflicts Manually"; -"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep." = "VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep."; +"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version." = "VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version."; /* Path collision migration shield (#45) — vaults an older version already merged onto one folder */ "Two Vaults Are Sharing One Folder" = "Two Vaults Are Sharing One Folder"; @@ -667,9 +618,7 @@ "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices."; "Vault Folder Was Moved or Deleted" = "Vault Folder Was Moved or Deleted"; "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes."; -"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own."; "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted."; -"Offer “%@” received — accepting…" = "Offer “%@” received — accepting…"; "Offer “%@” received — connect your Obsidian folder first." = "Offer “%@” received — connect your Obsidian folder first."; "Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen." = "Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen."; @@ -688,10 +637,8 @@ "Waiting for first sync" = "Waiting for first sync"; /* Onboarding guidance polish (#95) */ -"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing." = "Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing."; "The folder you selected is stored in iCloud Drive. iCloud can keep files as placeholders that are not fully downloaded on this iPhone, which can stall syncing and create conflicts. For reliable syncing, use your vaults under \"On My iPhone\" → \"Obsidian\" and select that folder instead." = "The folder you selected is stored in iCloud Drive. iCloud can keep files as placeholders that are not fully downloaded on this iPhone, which can stall syncing and create conflicts. For reliable syncing, use your vaults under \"On My iPhone\" → \"Obsidian\" and select that folder instead."; "Opens the setup checklist." = "Opens the setup checklist."; -"A vault offer was ignored on this iPhone, so it is not accepted automatically." = "A vault offer was ignored on this iPhone, so it is not accepted automatically."; "Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer." = "Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer."; "Camera Unavailable" = "Camera Unavailable"; "The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID." = "The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID."; @@ -940,3 +887,52 @@ "Upload check rate limited — upload unobserved" = "Upload check rate limited — upload unobserved"; "Upload check unsupported for this exact folder and peer" = "Upload check unsupported for this exact folder and peer"; "Upload capability unavailable — no upload evidence" = "Upload capability unavailable — no upload evidence"; +"Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review." = "Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review."; +"Conflict Safety Review Required" = "Conflict Safety Review Required"; +"Conflict Safety Status Unavailable" = "Conflict Safety Status Unavailable"; +"Background Sync Stopped for Safety" = "Background Sync Stopped for Safety"; +"Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable." = "Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable."; +"Conflict Safety Stop" = "Conflict Safety Stop"; +"Checking Conflict Safety" = "Checking Conflict Safety"; +"Conflict Recovery Unavailable" = "Conflict Recovery Unavailable"; +"Conflict recovery actions are not available in this version." = "Conflict recovery actions are not available in this version."; +"Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version." = "Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version."; +"Conflict Details" = "Conflict Details"; +"Review Conflicts" = "Review Conflicts"; +"VaultSync keeps receive-capable vaults read-only in this version." = "VaultSync keeps receive-capable vaults read-only in this version."; +"You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable." = "You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own." = "If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own."; +"Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version." = "Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version."; +"A vault offer is available for inspection." = "A vault offer is available for inspection."; +"Open Pending Shares to inspect the offer details. This version cannot accept it." = "Open Pending Shares to inspect the offer details. This version cannot accept it."; +"An ignored vault offer remains stored on this iPhone." = "An ignored vault offer remains stored on this iPhone."; +"Open Pending Shares to inspect its details. No action is available in this version." = "Open Pending Shares to inspect its details. No action is available in this version."; +"New share acceptance is unavailable in this version." = "New share acceptance is unavailable in this version."; +"New shared vault offers can be inspected, but not accepted in this version." = "New shared vault offers can be inspected, but not accepted in this version."; +"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version." = "Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version."; +"VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status." = "VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status."; +"Not syncing in this version — new share offers are inspection-only." = "Not syncing in this version — new share offers are inspection-only."; +"Set up VaultSync" = "Set up VaultSync"; +"Connect Obsidian and a device here. New shared vault offers are inspection-only in this version." = "Connect Obsidian and a device here. New shared vault offers are inspection-only in this version."; +"Offer “%@” is available for inspection only. This version cannot accept it." = "Offer “%@” is available for inspection only. This version cannot accept it."; +"Pending shares are read-only in this version." = "Pending shares are read-only in this version."; +"You can inspect who shared each offer, but no action is available." = "You can inspect who shared each offer, but no action is available."; +"Read Only" = "Read Only"; +"No Vaults Syncing" = "No Vaults Syncing"; +"Conflict Inspection Unavailable" = "Conflict Inspection Unavailable"; +"VaultSync cannot verify whether the conflict list is complete." = "VaultSync cannot verify whether the conflict list is complete."; +"Open conflicts to review any previously visible copies. No recovery action is available." = "Open conflicts to review any previously visible copies. No recovery action is available."; +"Current File" = "Current File"; +"Conflict Copy" = "Conflict Copy"; +"Added lines are from the conflict copy; removed lines are from the current file." = "Added lines are from the conflict copy; removed lines are from the current file."; +"(empty)" = "(empty)"; +"This copy is unavailable for inspection." = "This copy is unavailable for inspection."; +"Conflict inspection is unavailable." = "Conflict inspection is unavailable."; +"VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review." = "VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review."; +"Vault Folder Needs Manual Recovery" = "Vault Folder Needs Manual Recovery"; +"VaultSync can no longer verify the configured vault folder." = "VaultSync can no longer verify the configured vault folder."; +"Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically." = "Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically."; +"Vault configured" = "Vault configured"; +"At least one Send Only vault can continue uploading local changes." = "At least one Send Only vault can continue uploading local changes."; +"Existing receive-capable vaults are available for review only in this version." = "Existing receive-capable vaults are available for review only in this version."; +"Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version." = "Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version."; diff --git a/ios/VaultSync/es.lproj/InfoPlist.strings b/ios/VaultSync/es.lproj/InfoPlist.strings index 751beb5..d42966f 100644 --- a/ios/VaultSync/es.lproj/InfoPlist.strings +++ b/ios/VaultSync/es.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; "NSCameraUsageDescription" = "VaultSync usa la cámara para escanear los códigos QR de configuración y emparejamiento que elijas expresamente."; -"NSLocalNetworkUsageDescription" = "VaultSync se conecta directamente a tus otros dispositivos en la misma red para que tus bóvedas se sincronicen al instante, sin pasar por internet."; +"NSLocalNetworkUsageDescription" = "VaultSync usa la red local para conectarse directamente a tus otros dispositivos, comprobar el estado y subir cambios de Vaults de solo envío."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 23493a7..da7f8db 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -1,8 +1,4 @@ "About" = "Acerca de"; -"Accept First Pending Share" = "Aceptar primera compartición pendiente"; -"Accept Pending Share" = "Aceptar compartición pendiente"; -"Accept Share" = "Aceptar compartición"; -"Accept a share to activate syncing for that vault." = "Acepta una compartición para activar la sincronización de ese Vault."; "Action Needed" = "Acción necesaria"; "Actions" = "Acciones"; "Active" = "Activo"; @@ -45,11 +41,9 @@ "Completion" = "Progreso"; "Configuration Error" = "Error de configuración"; "Conflict Resolution Failed" = "No se pudo resolver el conflicto"; -"Conflict Resolved" = "Conflicto resuelto"; "Conflicted Files" = "Archivos en conflicto"; "Conflicts" = "Conflictos"; -"Conflicts mean multiple versions exist and need a manual decision." = "Los conflictos significan que existen varias versiones y hace falta decidir manualmente."; -"Connect Obsidian to accept shares" = "Conecta Obsidian para aceptar comparticiones"; +"A separate conflict copy was detected for a file." = "Se detectó una copia de conflicto separada de un archivo."; "Connect Obsidian Folder" = "Conectar carpeta de Obsidian"; "Connect to Obsidian first" = "Conecta primero con Obsidian"; "Connected" = "Conectado"; @@ -105,23 +99,16 @@ "Healthy" = "En buen estado"; "How to fix: %@" = "Cómo solucionarlo: %@"; "How to share from your computer" = "Cómo compartir desde tu ordenador"; -"If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep." = "Si una nota cambia en dos dispositivos a la vez, VaultSync puede avisarte para que elijas qué versión conservar."; +"If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version." = "Si VaultSync detecta copias en conflicto, puede avisarte para que las revises. Las acciones de recuperación no están disponibles en esta versión."; "Ignore for Now" = "Ignorar por ahora"; "Ignored shares (%d)" = "Comparticiones ignoradas (%d)"; "In progress" = "En curso"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Instala Obsidian desde la App Store y ábrelo una vez. La carpeta aparece después de que Obsidian la cree."; "Invalid Input" = "Entrada no válida"; "Invalid folder name: '%@'" = "Nombre de carpeta no válido: «%@»"; -"Keep Both" = "Conservar ambas"; "Keep Both did not change any files because the new copy name is already in use." = "Conservar ambas no cambió ningún archivo porque el nuevo nombre de la copia ya está en uso."; "Keep Both did not change any files because this storage location does not support safe renaming." = "Conservar ambas no cambió ningún archivo porque esta ubicación de almacenamiento no admite el cambio de nombre seguro."; -"Keep Other" = "Conservar la otra"; -"Keep Other Device's Version" = "Conservar la versión del otro dispositivo"; -"Keep This" = "Conservar esta"; -"Keep This Device's Version" = "Conservar la versión de este dispositivo"; -"Keep both versions" = "Conservar ambas versiones"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Mantén la app abierta un momento y vuelve a intentarlo. Si continúa, reinicia VaultSync."; -"Keeps your local file and renames the other device's file." = "Conserva tu archivo local y renombra el archivo del otro dispositivo."; "Last Check" = "Última comprobación"; "Last Failure" = "Último fallo"; "Last Relay Error" = "Último error del Relay"; @@ -179,7 +166,7 @@ "Open VaultSync" = "Abrir VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Abre VaultSync una vez para reiniciar Syncthing y vuelve a intentarlo."; "Open VaultSync to allow a longer foreground sync session." = "Abre VaultSync para permitir una sesión de sincronización más larga en primer plano."; -"Open conflicts and choose which version to keep." = "Abre los conflictos y elige qué versión conservar."; +"Open conflicts to see which copies are still available. Recovery actions are unavailable." = "Abre los conflictos para ver qué copias siguen disponibles. Las acciones de recuperación no están disponibles."; "Open full relay troubleshooting" = "Abrir la resolución de problemas completa del relay"; "Open iOS Notification Settings" = "Abrir los ajustes de notificaciones de iOS"; "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Abre Ajustes de iOS → VaultSync y comprueba que todos los permisos estén activados, luego vuelve a intentarlo."; @@ -187,11 +174,9 @@ "Opens discovery, relay, and notification settings." = "Abre los ajustes de detección, relay y notificaciones."; "Opens the form to add a Syncthing device." = "Abre el formulario para añadir un dispositivo de Syncthing."; "Optional" = "Opcional"; -"Overwrites your local file with the version from the other device." = "Sobrescribe tu archivo local con la versión del otro dispositivo."; "Path" = "Ruta"; "Peer connection is active." = "La conexión con el par está activa."; "Pending Shares" = "Comparticiones pendientes"; -"Pending shares are waiting to be accepted before sync can start." = "Las comparticiones pendientes esperan a ser aceptadas antes de que pueda comenzar la sincronización."; "Per-Device Provisioning" = "Aprovisionamiento por dispositivo"; "Permission Required" = "Permiso requerido"; "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Selecciona una carpeta. En el selector elige \"En mi iPhone\" → \"Obsidian\"."; @@ -206,7 +191,7 @@ "Reconnect Obsidian Folder" = "Volver a conectar la carpeta de Obsidian"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "Vuelve a conectar el acceso a Obsidian o ajusta los permisos de la carpeta en el dispositivo anfitrión."; "Reconnect devices or add missing peers to restore continuous sync." = "Vuelve a conectar dispositivos o añade los pares que falten para restaurar la sincronización continua."; -"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "Vuelve a conectar el acceso a tu carpeta de Obsidian en VaultSync y luego ejecuta un reescaneo en primer plano."; +"Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version." = "Vuelve a conectar el acceso a tu carpeta de Obsidian en VaultSync y luego revisa el Vault afectado. Los Vaults que pueden recibir permanecen en modo de solo lectura en esta versión."; "Recreate or reselect the folder, then trigger a rescan." = "Vuelve a crear o a seleccionar la carpeta y luego inicia un reescaneo."; "Registered" = "Registrado"; "Relay Backend" = "Backend del Relay"; @@ -235,19 +220,16 @@ "Remove and re-share the folder from your desktop device." = "Elimina y vuelve a compartir la carpeta desde tu equipo de escritorio."; "Removed line. %@" = "Línea eliminada. %@"; "Rename Failed" = "No se pudo renombrar"; -"Rename the existing copy in Files, then try Keep Both again." = "Cambia el nombre de la copia existente en Archivos y vuelve a intentar conservar ambas."; -"Resolve this conflict manually in Files without replacing either file." = "Resuelve este conflicto manualmente en Archivos sin reemplazar ninguno de los archivos."; +"Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available." = "Deja ambas copias sin cambios. No repitas esta acción hasta que haya una recuperación verificada por separado."; "Renews" = "Se renueva"; "Requesting camera access…" = "Solicitando acceso a la cámara…"; "Rescan Failed" = "El reescaneo falló"; "Rescan Failed Vaults" = "Reescanear Vaults con fallos"; "Rescan Vault" = "Reescanear Vault"; "Rescan failed vaults, then verify folder access and permissions." = "Reescanea los Vaults con fallos y luego verifica el acceso y los permisos de la carpeta."; -"Resolve Conflict" = "Resolver conflicto"; -"Resolve Conflicts" = "Resolver conflictos"; "Restore Purchases" = "Restaurar compras"; "Restore Share" = "Restaurar compartición"; -"A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "Un conflicto ocurre cuando un archivo se edita en dos dispositivos a la vez. Syncthing guarda ambas versiones para evitar la pérdida de datos."; +"A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available." = "Puede producirse un conflicto cuando un archivo cambia al mismo tiempo en dos dispositivos. Revisa cada copia visible, porque la conservación automática no puede garantizar que todas las versiones sigan disponibles."; "APNs Registration" = "Registro de APNs"; "APNs Token" = "Token de APNs"; "Context: %@ · %@" = "Contexto: %@ · %@"; @@ -256,7 +238,6 @@ "Present" = "Presente"; "Purchase Failed" = "La compra falló"; "Rescan All Vaults" = "Reescanear todos los Vaults"; -"Review and Accept" = "Revisar y aceptar"; "Selects this plan." = "Selecciona este plan."; "Something Went Wrong" = "Algo salió mal"; "Sync Conflicts" = "Conflictos de sincronización"; @@ -279,8 +260,6 @@ "Scanning completed in %@" = "Escaneo completado en %@"; "Scanning started in %@" = "Escaneo iniciado en %@"; "Settings" = "Ajustes"; -"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Comparte una carpeta desde Syncthing en tu escritorio — se aceptará automáticamente."; -"Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Las solicitudes de compartición se muestran abajo, pero Aceptar y Reintentar están desactivados hasta que tu carpeta de Obsidian esté conectada."; "Shared" = "Compartido"; "Shared With" = "Compartido con"; "Shared by an unknown device" = "Compartido por un dispositivo desconocido"; @@ -318,8 +297,6 @@ "Terms of Use" = "Términos de uso"; "The device will be disconnected and removed from all shared folders." = "El dispositivo se desconectará y se eliminará de todas las carpetas compartidas."; "The embedded Syncthing bridge did not start for a background sync." = "El puente integrado de Syncthing no se inició para una sincronización en segundo plano."; -"The file '%@' was kept as your local version. The other device's version was discarded." = "El archivo «%@» se conservó como tu versión local. La versión del otro dispositivo se descartó."; -"The file '%@' was overwritten with the version from the other device." = "El archivo «%@» se sobrescribió con la versión del otro dispositivo."; "The folder path no longer exists%@." = "La ruta de la carpeta ya no existe%@."; "The folder scan finished successfully." = "El escaneo de la carpeta finalizó correctamente."; "The sync engine stopped unexpectedly." = "El motor de sincronización se detuvo inesperadamente."; @@ -359,7 +336,6 @@ "VaultSync does not have the required permission for this action." = "VaultSync no tiene el permiso necesario para esta acción."; "VaultSync found a configuration problem." = "VaultSync detectó un problema de configuración."; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync necesita acceso a la cámara para escanear los códigos QR de ID de dispositivo de Syncthing. Actívalo en Ajustes."; -"VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync necesita acceso por única vez a tu carpeta de Obsidian antes de poder aceptar comparticiones."; "VaultSync reported an unexpected error." = "VaultSync informó de un error inesperado."; "Wait a moment and retry." = "Espera un momento y vuelve a intentarlo."; "What is a conflict?" = "¿Qué es un conflicto?"; @@ -370,8 +346,8 @@ "hours" = "horas"; "No Activity Yet" = "Aún no hay actividad"; "Sync engine running" = "Motor de sincronización en ejecución"; -"onboarding.welcome.title" = "Tus notas de Obsidian. Sincronizadas en privado."; -"onboarding.welcome.subtitle" = "Mantén tu Vault sincronizado entre tus propios dispositivos, sin depender del almacenamiento en la nube de terceros."; +"onboarding.welcome.title" = "Tu configuración de Obsidian. Privada por diseño."; +"onboarding.welcome.subtitle" = "Conecta Obsidian y tus propios dispositivos. Las nuevas ofertas de Vault compartido se pueden revisar, pero no aceptar en esta versión."; "onboarding.welcome.benefit.private" = "Privado entre tus dispositivos"; "onboarding.welcome.benefit.obsidian" = "Diseñado para Vaults de Obsidian"; "onboarding.welcome.benefit.noCloud" = "No requiere cuenta en la nube"; @@ -388,18 +364,14 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync no puede acceder a tu carpeta local de Obsidian."; "Connect your Obsidian folder from the VaultSync home screen." = "Conecta tu carpeta de Obsidian desde la pantalla de inicio de VaultSync."; "Computer or server added" = "Ordenador o servidor añadido"; -"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Tu primera sincronización está lista. Cloud Relay envía una señal de activación a este iPhone en cuanto cambian tus notas, incluso con la app cerrada."; +"Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes." = "Cloud Relay puede activar este iPhone para realizar comprobaciones en segundo plano. Los Vaults de solo envío pueden subir cambios locales."; "Your iPhone is paired with at least one Syncthing device." = "Tu iPhone está emparejado con al menos un dispositivo de Syncthing."; "Your iPhone is not paired with a Syncthing device yet." = "Tu iPhone aún no está emparejado con ningún dispositivo de Syncthing."; "Add your computer or server from the Devices section on the home screen." = "Añade tu ordenador o servidor desde la sección Dispositivos en la pantalla de inicio."; -"Vault syncing" = "Vault sincronizándose"; +"Vault setup" = "Configuración del Vault"; "At least one Obsidian vault is active in VaultSync." = "Al menos un Vault de Obsidian está activo en VaultSync."; -"A vault offer is waiting to be accepted." = "Hay una oferta de Vault esperando a ser aceptada."; -"A vault offer is waiting. Accept it from Pending Shares on the home screen." = "Hay una oferta de Vault esperando. Acéptala en Comparticiones pendientes en la pantalla de inicio."; -"A vault offer was seen earlier, but no vault is syncing right now." = "Antes se detectó una oferta de Vault, pero ahora mismo no se está sincronizando ningún Vault."; -"If syncing has not started, share your Obsidian vault again from Syncthing on your computer." = "Si la sincronización no ha comenzado, vuelve a compartir tu Vault de Obsidian desde Syncthing en tu ordenador."; +"A vault offer was seen earlier, but no vault is configured right now." = "Antes se detectó una oferta de Vault, pero ahora mismo no hay ningún Vault configurado."; "No Obsidian vault is active in VaultSync yet." = "Aún no hay ningún Vault de Obsidian activo en VaultSync."; -"Share your Obsidian vault from Syncthing on your computer." = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador."; "VaultSync’s sync engine is running." = "El motor de sincronización de VaultSync está en ejecución."; "VaultSync’s sync engine is still starting or unavailable." = "El motor de sincronización de VaultSync todavía se está iniciando o no está disponible."; "If this stays unavailable, restart VaultSync and check the home screen for issues." = "Si sigue sin estar disponible, reinicia VaultSync y revisa la pantalla de inicio en busca de problemas."; @@ -415,12 +387,6 @@ "Skip these on this iPhone? You can change this anytime in Sync Filters." = "¿Omitir estos en este iPhone? Puedes cambiarlo cuando quieras en Filtros de sincronización."; "Skip" = "Omitir"; "Choose what gets synced to this iPhone" = "Elige qué se sincroniza en este iPhone"; -"Always skip on this iPhone" = "Omitir siempre en este iPhone"; -"More actions" = "Más acciones"; -"Skipping enabled" = "Omisión activada"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "«%@» y sus copias de conflicto dejarán de sincronizarse en este iPhone. Puedes deshacerlo en Filtros de sincronización."; -"1 existing conflict copy was removed." = "Se eliminó 1 copia de conflicto existente."; -"%d existing conflict copies were removed." = "Se eliminaron %d copias de conflicto existentes."; "+ conflict copies" = "+ copias de conflicto"; "Could not add filter" = "No se pudo añadir el filtro"; "Could not save filters" = "No se pudieron guardar los filtros"; @@ -450,17 +416,17 @@ "%d Required Devices Are Disconnected" = "%d dispositivos necesarios están desconectados"; "1 Pending Share Needs Attention" = "1 compartición pendiente requiere atención"; "%d Pending Shares Need Attention" = "%d comparticiones pendientes requieren atención"; -"1 Conflict Needs Resolution" = "1 conflicto necesita resolverse"; -"%d Conflicts Need Resolution" = "%d conflictos necesitan resolverse"; +"1 Conflict Available for Review" = "1 conflicto disponible para revisar"; +"%d Conflicts Available for Review" = "%d conflictos disponibles para revisar"; /* Issue #10 — conflict notification body */ -"1 file has a sync conflict. Open VaultSync to resolve it." = "1 archivo tiene un conflicto de sincronización. Abre VaultSync para resolverlo."; -"%d files have sync conflicts. Open VaultSync to resolve them." = "%d archivos tienen conflictos de sincronización. Abre VaultSync para resolverlos."; +"1 file has a sync conflict. Open VaultSync to see which copies are still available." = "1 archivo tiene un conflicto de sincronización. Abre VaultSync para ver qué copias siguen disponibles."; +"%d files have sync conflicts. Open VaultSync to see which copies are still available." = "%d archivos tienen conflictos de sincronización. Abre VaultSync para ver qué copias siguen disponibles."; /* Issue #10 — conflict-notifications toggle (Settings) */ "Notifications" = "Notificaciones"; "Conflict Notifications" = "Notificaciones de conflictos"; -"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Muestra un aviso cuando se detectan conflictos de sincronización. Desactivarlo no afecta a Cloud Relay ni a la sincronización en segundo plano: tu Vault sigue sincronizándose."; +"Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads." = "Muestra un aviso cuando se detectan copias en conflicto. Desactivarlo no afecta a Cloud Relay, las comprobaciones en segundo plano ni las subidas de los Vaults de solo envío."; /* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ "Alert Banners" = "Avisos"; @@ -472,7 +438,7 @@ "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "El registro de APNs falló. Comprueba tu conexión a internet y reintenta el registro. El push silencioso no requiere que los avisos de notificación estén activados."; /* Issue #10 — background-sync reliability (error-settled outcome) */ -"Background sync settled with at least one folder in an error state." = "La sincronización en segundo plano terminó con al menos una carpeta en un estado de error."; +"Background sync stopped because the engine or a folder reported a safety issue." = "La sincronización en segundo plano se detuvo porque el motor o una carpeta notificó un problema de seguridad."; /* Issue #10 — relay reachable (vs delivering) */ "Cloud Relay looks reachable" = "Cloud Relay parece accesible"; @@ -508,18 +474,12 @@ "Remove this device?" = "¿Eliminar este dispositivo?"; "Double-tap to share this vault with this device." = "Toca dos veces para compartir este Vault con este dispositivo."; "Double-tap to stop sharing this vault with this device." = "Toca dos veces para dejar de compartir este Vault con este dispositivo."; -"All conflicts resolved" = "Todos los conflictos resueltos"; +"No conflicts found" = "No se encontraron conflictos"; "Loading files…" = "Cargando archivos…"; "Computing diff…" = "Calculando diferencias…"; -"Other Device" = "Otro dispositivo"; -"Other Device (%@)" = "Otro dispositivo (%@)"; -"Added lines come from the other device; removed lines are your version on this device." = "Las líneas añadidas provienen del otro dispositivo; las líneas eliminadas son tu versión en este dispositivo."; -"(empty or unreadable)" = "(vacío o ilegible)"; -"a new name" = "un nombre nuevo"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Se conservaron ambas versiones.\n\nTu versión local permanece como «%@».\nLa versión del otro dispositivo se renombró a «%@»."; "%d conflicts" = "%d conflictos"; "Your contribution is pending approval." = "Tu contribución está pendiente de aprobación."; -"iOS did not provide a push token required for instant sync." = "iOS no proporcionó un token de push necesario para la sincronización instantánea."; +"iOS did not provide a push token required for Cloud Relay wake-ups." = "iOS no proporcionó el token de push necesario para las señales de activación de Cloud Relay."; "%@ (Trigger: %@)" = "%@ (Activador: %@)"; "1 additional file synced in %@" = "1 archivo adicional sincronizado en %@"; "%d additional files synced in %@" = "%d archivos adicionales sincronizados en %@"; @@ -535,7 +495,6 @@ "No folders were available after forced silent-push restart." = "No había carpetas disponibles tras el reinicio forzado por push silencioso."; "No folders were available for background sync." = "No había carpetas disponibles para la sincronización en segundo plano."; "No security-scoped bookmark access was available." = "No había acceso disponible mediante marcador de ámbito de seguridad."; -"Accept or create a shared vault before relying on background sync." = "Acepta o crea un Vault compartido antes de depender de la sincronización en segundo plano."; "Retry from the app and review relay/background diagnostics in Settings." = "Reintenta desde la app y revisa el diagnóstico de relay/segundo plano en Ajustes."; "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "El push silencioso reinició Syncthing, pero no se observó ningún progreso real de sincronización antes de que la app volviera al estado inactivo."; "Sync did not reach idle before %ds deadline." = "La sincronización no alcanzó el estado inactivo antes del plazo de %ds."; @@ -548,12 +507,12 @@ "relay network: %@" = "red del relay: %@"; /* Cloud Relay activation (2026-05-31): honest server-setup + conversion */ -"Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync." = "Cloud Relay no está activado. Sin él, los cambios entrantes llegan cuando abres VaultSync."; -"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Activa Cloud Relay en la pestaña Relay si quieres que los cambios se envíen en el momento en que ocurren."; +"Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies." = "Cloud Relay no está activado. Abre VaultSync para revisar el estado actual y las copias en conflicto."; +"Enable Cloud Relay on the Relay tab to wake VaultSync for background checks." = "Activa Cloud Relay en la pestaña Relay para que VaultSync se active y realice comprobaciones en segundo plano."; "Cloud Relay — finish server setup" = "Cloud Relay: completa la configuración del servidor"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "Tienes una suscripción, pero aún no ha llegado ninguna señal de activación reciente. Asegúrate de que el asistente vaultsync-notify se está ejecutando en tu servidor."; "Set up the server helper from the Relay tab → Set Up Your Server." = "Configura el asistente del servidor en la pestaña Relay → Configura tu servidor."; -"Wake-ups are being delivered — incoming changes sync the moment they happen." = "Las señales de activación se están entregando: los cambios entrantes se sincronizan en el momento en que ocurren."; +"Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes." = "Se están entregando las señales de activación. VaultSync puede comprobar el estado en segundo plano y los Vaults de solo envío pueden subir cambios locales."; "Your server helper is running — wake-ups are being delivered." = "Tu asistente del servidor está en marcha: se están entregando las señales de activación."; "Why this step" = "Por qué este paso"; "Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay necesita un pequeño asistente en tu servidor. Vigila los cambios en Syncthing y envía a VaultSync una señal de activación; nunca ve tus notas. Sin él, la suscripción no tiene con qué despertar la app."; @@ -570,7 +529,7 @@ "Last wake-up" = "Última señal de activación"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente hasta que se cancele. Cancela cuando quieras en Ajustes → Suscripciones."; "Cancel anytime in Settings → Subscriptions" = "Cancela cuando quieras en Ajustes → Suscripciones"; -"Get instant updates" = "Recibe actualizaciones instantáneas"; +"Enable background wake-ups" = "Activar señales en segundo plano"; "Turn on Cloud Relay" = "Activar Cloud Relay"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ @@ -581,35 +540,28 @@ "Add your server first" = "Añade primero tu servidor"; "Best value" = "La mejor opción"; "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe." = "Cloud Relay despierta un dispositivo concreto. Vincula en la pestaña «Dispositivos» el ordenador o servidor que aloja tu Vault y luego vuelve para suscribirte."; -"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Completa estos pasos aquí mismo. Se iluminan en verde a medida que avanzas, y siempre puedes terminarlos más tarde desde la pantalla de inicio."; "Connect your Obsidian folder" = "Conecta tu carpeta de Obsidian"; "Double tap to copy" = "Toca dos veces para copiar"; -"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Concede a VaultSync acceso único a tu carpeta local de Obsidian para que pueda sincronizar tus notas."; "How is this private?" = "¿Cómo es esto privado?"; -"Instant sync, still private" = "Sincronización instantánea, sigue siendo privado"; -"Let’s get your vault synced" = "Vamos a sincronizar tu Vault"; +"Private background wake-ups" = "Señales de activación privadas en segundo plano"; "Loading plans…" = "Cargando planes…"; "Manage" = "Gestionar"; "Monthly" = "Mensual"; "One step left to activate" = "Falta un paso para activar"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Opcional: activa Cloud Relay más tarde para recibir actualizaciones instantáneas; lo encontrarás en la pestaña Relay."; +"Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab." = "Opcional: activa Cloud Relay más tarde para recibir señales de activación en segundo plano; lo encontrarás en la pestaña Relay."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Vincula este iPhone con el dispositivo de Syncthing que aloja tu Vault, mediante el ID de dispositivo o un código QR."; "Relay" = "Relay"; "Relay health & diagnostics" = "Estado y diagnóstico del Relay"; -"One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes." = "Solo queda un paso: ejecuta una única línea en tu servidor y empezarán las actualizaciones instantáneas. El asistente solo envía una señal de activación: nunca ve tus notas."; +"One step left: run one command on your server to enable background wake-ups. The helper never sees your notes." = "Solo queda un paso: ejecuta un comando en tu servidor para activar las señales de activación en segundo plano. El asistente nunca ve tus notas."; "Save %d%%" = "Ahorra un %d %%"; "Server helper setup" = "Configuración del asistente del servidor"; "Set up the server helper" = "Configurar el asistente del servidor"; -"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador. VaultSync lo acepta automáticamente: se pondrá verde en cuanto llegue."; "Starts a subscription purchase." = "Inicia la compra de una suscripción."; "Sync" = "Sincronizar"; -"Sync your first vault" = "Sincroniza tu primer Vault"; -"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Se están entregando las señales de activación: los cambios de tus otros dispositivos llegan al instante."; "Yearly" = "Anual"; -"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Se conserva tu versión local y la versión del otro dispositivo se añade con un nombre nuevo. No se descarta nada."; -"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Tus notas nunca pasan por nuestros servidores. Cloud Relay envía una pequeña señal de activación para que los cambios de tus otros dispositivos lleguen en el momento en que ocurren, incluso con la app cerrada."; +"Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes." = "Tus notas nunca pasan por nuestros servidores. Cloud Relay solo envía una señal de activación para que VaultSync pueda comprobar el estado en segundo plano. Los Vaults de solo envío pueden subir cambios locales."; "One-step setup: a single line on your server." = "Configuración en un solo paso: una única línea en tu servidor."; -"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Tu Vault ya se sincroniza gratis y de igual a igual. Relay solo elimina la espera de «abrir la app para sincronizar»; no es almacenamiento en la nube."; +"VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes." = "VaultSync sigue siendo de igual a igual. Relay proporciona señales de activación en segundo plano; no es almacenamiento en la nube y nunca transporta tus notas."; "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "Estás suscrito, pero aún no ha llegado ninguna señal de activación. Cloud Relay solo entrega una vez que el asistente se está ejecutando en tu servidor."; /* Folder path resilience (#25) */ @@ -629,7 +581,6 @@ "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "Estás suscrito, pero tu servidor nunca ha despertado este iPhone. Un paso completa la configuración."; "Opens Cloud Relay setup." = "Abre la configuración de Cloud Relay."; "Cloud Relay went quiet" = "Cloud Relay está en silencio"; -"Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "Tu servidor acaba de conectarse con este iPhone. Los cambios entrantes ahora se sincronizan en el momento en que ocurren."; "Great" = "Genial"; "Not active yet" = "Aún no activo"; "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "Estás suscrito. Las señales de activación comienzan cuando el asistente esté en marcha en el ordenador o servidor que mantienes encendido: completa la configuración abajo."; @@ -646,7 +597,7 @@ /* Manual conflict review — notes and .obsidian state (Settings) */ "Review Conflicts Manually" = "Revisar conflictos manualmente"; -"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep." = "VaultSync no elige automáticamente entre copias en conflicto de tus notas, los ajustes de Obsidian o el estado de los plugins. Revisa cada conflicto y decide qué conservar."; +"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version." = "VaultSync no elige automáticamente entre copias en conflicto de tus notas, los ajustes de Obsidian o el estado de los plugins. Puedes inspeccionar cada conflicto, pero las acciones de recuperación no están disponibles en esta versión."; /* Path collision migration shield (#45) — vaults an older version already merged onto one folder */ "Two Vaults Are Sharing One Folder" = "Dos Vaults comparten una misma carpeta"; @@ -667,9 +618,7 @@ "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "La carpeta \"%@\" ya contiene archivos. Aceptar esta compartición combinaría su contenido con el Vault compartido y sincronizaría el resultado con los demás dispositivos."; "Vault Folder Was Moved or Deleted" = "La carpeta del Vault se movió o se eliminó"; "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync ya no puede verificar que esta carpeta siga conteniendo los datos de este Vault%@: probablemente se movió, se renombró, se reemplazó o se eliminó fuera de VaultSync. La sincronización se ha detenido para proteger tus notas."; -"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "Si moviste o renombraste la carpeta, devuélvela a su ubicación original. Si ya no existe, elimina este Vault en este iPhone y acéptalo de nuevo en \"Comparticiones pendientes\". VaultSync nunca mueve, recrea ni elimina carpetas por su cuenta."; "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Sigue los pasos de recuperación que se muestran junto al Vault afectado: un nuevo escaneo no puede reparar una carpeta de Vault movida o eliminada."; -"Offer “%@” received — accepting…" = "Oferta \"%@\" recibida — aceptando…"; "Offer “%@” received — connect your Obsidian folder first." = "Oferta \"%@\" recibida — conecta primero tu carpeta de Obsidian."; "Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen." = "La oferta \"%@\" necesita tu atención. Toca \"Terminar la configuración más tarde\" abajo para revisarla en la pantalla principal."; @@ -688,10 +637,8 @@ "Waiting for first sync" = "Esperando la primera sincronización"; /* Onboarding guidance polish (#95) */ -"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing." = "Dispositivo añadido. Ahora confirma este iPhone en Syncthing en tu ordenador — allí aparece una solicitud de confirmación. Después comparte tu Vault para empezar a sincronizar."; "The folder you selected is stored in iCloud Drive. iCloud can keep files as placeholders that are not fully downloaded on this iPhone, which can stall syncing and create conflicts. For reliable syncing, use your vaults under \"On My iPhone\" → \"Obsidian\" and select that folder instead." = "La carpeta que seleccionaste está en iCloud Drive. iCloud puede mantener archivos como marcadores de posición no descargados por completo en este iPhone, lo que puede detener la sincronización y crear conflictos. Para una sincronización fiable, guarda tus Vaults en \"En mi iPhone\" → \"Obsidian\" y selecciona esa carpeta en su lugar."; "Opens the setup checklist." = "Abre la lista de comprobación de configuración."; -"A vault offer was ignored on this iPhone, so it is not accepted automatically." = "Una oferta de Vault fue ignorada en este iPhone, así que no se acepta automáticamente."; "Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer." = "Abre \"Comparticiones ignoradas\" en las comparticiones pendientes de la pantalla de inicio y toca \"Restaurar compartición\". Compartir de nuevo desde tu ordenador no creará una nueva oferta."; "Camera Unavailable" = "Cámara no disponible"; "The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID." = "No se pudo iniciar la cámara en este dispositivo. Introduce el ID del dispositivo manualmente — en Syncthing en tu ordenador, elige Acciones → Mostrar ID."; @@ -940,3 +887,52 @@ "Upload check rate limited — upload unobserved" = "Comprobación de carga limitada por frecuencia — carga sin observar"; "Upload check unsupported for this exact folder and peer" = "Comprobación de carga no compatible con esta carpeta y este par exactos"; "Upload capability unavailable — no upload evidence" = "Capacidad de carga no disponible — sin evidencia de carga"; +"Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review." = "Las comprobaciones de carga y descarga no están disponibles mientras los cambios del lado receptor estén desactivados. Los detalles del emparejamiento siguen disponibles para revisión."; +"Conflict Safety Review Required" = "Se requiere revisar la seguridad de los conflictos"; +"Conflict Safety Status Unavailable" = "Estado de seguridad de conflictos no disponible"; +"Background Sync Stopped for Safety" = "La sincronización en segundo plano se detuvo por seguridad"; +"Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable." = "Abre VaultSync para revisar el problema de seguridad. Deja sin cambios las copias en conflicto mientras no haya una recuperación segura disponible."; +"Conflict Safety Stop" = "Parada de seguridad de conflictos"; +"Checking Conflict Safety" = "Comprobando la seguridad de conflictos"; +"Conflict Recovery Unavailable" = "Recuperación de conflictos no disponible"; +"Conflict recovery actions are not available in this version." = "Las acciones de recuperación de conflictos no están disponibles en esta versión."; +"Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version." = "Revisa aquí las copias que siguen disponibles. Deja los archivos sin cambios; VaultSync no puede ejecutar una acción de recuperación en esta versión."; +"Conflict Details" = "Detalles del conflicto"; +"Review Conflicts" = "Revisar conflictos"; +"VaultSync keeps receive-capable vaults read-only in this version." = "VaultSync mantiene los Vaults que pueden recibir en modo de solo lectura en esta versión."; +"You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable." = "Puedes revisar el estado disponible y las copias en conflicto, pero los cambios de recepción y la recuperación de conflictos no están disponibles."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own." = "Si moviste la carpeta o cambiaste su nombre, devuélvela a su ubicación original. Si ya no existe, mantén este Vault detenido y conserva todas las copias restantes. En esta versión no se pueden aceptar nuevas comparticiones. VaultSync nunca mueve, vuelve a crear ni elimina carpetas por su cuenta."; +"Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version." = "Mantén pausados los Vaults afectados y conserva todas las copias restantes. En esta versión no se pueden aceptar nuevas comparticiones."; +"A vault offer is available for inspection." = "Hay una oferta de Vault disponible para revisar."; +"Open Pending Shares to inspect the offer details. This version cannot accept it." = "Abre Comparticiones pendientes para revisar los detalles de la oferta. Esta versión no puede aceptarla."; +"An ignored vault offer remains stored on this iPhone." = "Una oferta de Vault ignorada sigue guardada en este iPhone."; +"Open Pending Shares to inspect its details. No action is available in this version." = "Abre Comparticiones pendientes para revisar sus detalles. No hay ninguna acción disponible en esta versión."; +"New share acceptance is unavailable in this version." = "En esta versión no se pueden aceptar nuevas comparticiones."; +"New shared vault offers can be inspected, but not accepted in this version." = "Las nuevas ofertas de Vault compartido se pueden revisar, pero no aceptar en esta versión."; +"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version." = "Dispositivo añadido. Ahora confirma este iPhone en Syncthing desde tu ordenador: allí aparecerá una solicitud de confirmación. Las nuevas ofertas de Vault compartido solo se pueden revisar en esta versión."; +"VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status." = "VaultSync necesita acceso una vez a tu carpeta de Obsidian para revisar los Vaults locales y el estado de sincronización existente."; +"Not syncing in this version — new share offers are inspection-only." = "Sin sincronización en esta versión: las nuevas ofertas de compartición son solo para revisión."; +"Set up VaultSync" = "Configura VaultSync"; +"Connect Obsidian and a device here. New shared vault offers are inspection-only in this version." = "Conecta aquí Obsidian y un dispositivo. Las nuevas ofertas de Vault compartido solo se pueden revisar en esta versión."; +"Offer “%@” is available for inspection only. This version cannot accept it." = "La oferta «%@» solo está disponible para revisión. Esta versión no puede aceptarla."; +"Pending shares are read-only in this version." = "Las comparticiones pendientes son de solo lectura en esta versión."; +"You can inspect who shared each offer, but no action is available." = "Puedes revisar quién compartió cada oferta, pero no hay ninguna acción disponible."; +"Read Only" = "Solo lectura"; +"No Vaults Syncing" = "No hay Vaults sincronizándose"; +"Conflict Inspection Unavailable" = "Inspección de conflictos no disponible"; +"VaultSync cannot verify whether the conflict list is complete." = "VaultSync no puede verificar si la lista de conflictos está completa."; +"Open conflicts to review any previously visible copies. No recovery action is available." = "Abre Conflictos para revisar las copias visibles anteriormente. No hay ninguna acción de recuperación disponible."; +"Current File" = "Archivo actual"; +"Conflict Copy" = "Copia en conflicto"; +"Added lines are from the conflict copy; removed lines are from the current file." = "Las líneas añadidas proceden de la copia en conflicto; las eliminadas, del archivo actual."; +"(empty)" = "(vacío)"; +"This copy is unavailable for inspection." = "Esta copia no está disponible para inspeccionarla."; +"Conflict inspection is unavailable." = "La inspección de conflictos no está disponible."; +"VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review." = "VaultSync no puede verificar si la lista de conflictos está completa. Las copias visibles anteriormente se siguen mostrando para revisarlas."; +"Vault Folder Needs Manual Recovery" = "La carpeta del Vault necesita recuperación manual"; +"VaultSync can no longer verify the configured vault folder." = "VaultSync ya no puede verificar la carpeta configurada del Vault."; +"Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically." = "Mantén este Vault detenido y conserva todas las copias restantes. Restaura la carpeta original en su ubicación original; VaultSync no la moverá ni la redirigirá automáticamente."; +"Vault configured" = "Vault configurado"; +"At least one Send Only vault can continue uploading local changes." = "Al menos un Vault de solo envío puede seguir subiendo cambios locales."; +"Existing receive-capable vaults are available for review only in this version." = "Los Vaults existentes que pueden recibir están disponibles solo para revisión en esta versión."; +"Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version." = "Abre VaultSync para revisar la configuración del dispositivo y las carpetas. La aceptación de nuevas carpetas compartidas no está disponible en esta versión."; diff --git a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings index 2b22814..9861a68 100644 --- a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings +++ b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; "NSCameraUsageDescription" = "VaultSync 仅使用相机扫描你主动选择的 Syncthing 设备 ID 二维码和受控诊断配对二维码。"; -"NSLocalNetworkUsageDescription" = "VaultSync 直接连接同一网络中的其他设备,让你的仓库即时同步,无需绕道互联网。"; +"NSLocalNetworkUsageDescription" = "VaultSync 使用本地网络直接连接你的其他设备,以检查状态并上传仅发送 Vault 中的更改。"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 8490a4a..d89ad18 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -1,8 +1,4 @@ "About" = "关于"; -"Accept First Pending Share" = "接受第一个待处理共享"; -"Accept Pending Share" = "接受待处理共享"; -"Accept Share" = "接受共享"; -"Accept a share to activate syncing for that vault." = "接受一个共享以激活该 Vault 的同步。"; "Action Needed" = "需要操作"; "Actions" = "操作"; "Active" = "已激活"; @@ -45,11 +41,9 @@ "Completion" = "完成度"; "Configuration Error" = "配置错误"; "Conflict Resolution Failed" = "冲突解决失败"; -"Conflict Resolved" = "冲突已解决"; "Conflicted Files" = "冲突文件"; "Conflicts" = "冲突"; -"Conflicts mean multiple versions exist and need a manual decision." = "冲突表示存在多个版本,需要手动决定保留哪一个。"; -"Connect Obsidian to accept shares" = "连接 Obsidian 以接受共享"; +"A separate conflict copy was detected for a file." = "检测到某个文件存在单独的冲突副本。"; "Connect Obsidian Folder" = "连接 Obsidian 文件夹"; "Connect to Obsidian first" = "请先连接 Obsidian"; "Connected" = "已连接"; @@ -105,23 +99,16 @@ "Healthy" = "正常"; "How to fix: %@" = "修复方法:%@"; "How to share from your computer" = "如何从电脑共享"; -"If a note changes on two devices at the same time, VaultSync can alert you so you can choose which version to keep." = "如果一条笔记同时在两台设备上被修改,VaultSync 可以提醒你,由你决定保留哪个版本。"; +"If VaultSync detects conflicting copies, it can alert you so you can inspect them. Recovery actions are unavailable in this version." = "如果 VaultSync 检测到冲突副本,它可以提醒你进行检查。此版本不提供恢复操作。"; "Ignore for Now" = "暂时忽略"; "Ignored shares (%d)" = "已忽略的共享(%d)"; "In progress" = "进行中"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "从 App Store 安装 Obsidian 并打开一次。文件夹会在 Obsidian 创建后出现。"; "Invalid Input" = "输入无效"; "Invalid folder name: '%@'" = "无效的文件夹名称:“%@”"; -"Keep Both" = "两者都保留"; "Keep Both did not change any files because the new copy name is already in use." = "“两者都保留”未更改任何文件,因为新副本名称已被使用。"; "Keep Both did not change any files because this storage location does not support safe renaming." = "“两者都保留”未更改任何文件,因为此存储位置不支持安全重命名。"; -"Keep Other" = "保留对方版本"; -"Keep Other Device's Version" = "保留另一台设备的版本"; -"Keep This" = "保留此版本"; -"Keep This Device's Version" = "保留此设备版本"; -"Keep both versions" = "保留两个版本"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "请保持应用打开片刻后重试。如果问题持续存在,请重启 VaultSync。"; -"Keeps your local file and renames the other device's file." = "保留你的本地文件,并重命名另一台设备的文件。"; "Last Check" = "上次检查"; "Last Failure" = "上次失败"; "Last Relay Error" = "上次 Relay 错误"; @@ -179,7 +166,7 @@ "Open VaultSync" = "打开 VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "打开 VaultSync 一次以重启 Syncthing,然后重试。"; "Open VaultSync to allow a longer foreground sync session." = "打开 VaultSync,以允许更长的前台同步会话。"; -"Open conflicts and choose which version to keep." = "打开冲突并选择要保留的版本。"; +"Open conflicts to see which copies are still available. Recovery actions are unavailable." = "打开冲突以查看哪些副本仍然可用。恢复操作不可用。"; "Open full relay troubleshooting" = "打开完整 Relay 故障排查"; "Open iOS Notification Settings" = "打开 iOS 通知设置"; "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "打开 iOS 设置 → VaultSync,检查所有权限都已启用,然后重试。"; @@ -187,11 +174,9 @@ "Opens discovery, relay, and notification settings." = "打开发现、Relay 和通知设置。"; "Opens the form to add a Syncthing device." = "打开添加 Syncthing 设备的表单。"; "Optional" = "可选"; -"Overwrites your local file with the version from the other device." = "使用另一台设备的版本覆盖你的本地文件。"; "Path" = "路径"; "Peer connection is active." = "对端连接已激活。"; "Pending Shares" = "待处理共享"; -"Pending shares are waiting to be accepted before sync can start." = "待处理共享需要先被接受,同步才能开始。"; "Per-Device Provisioning" = "按设备配置"; "Permission Required" = "需要权限"; "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "请选择一个文件夹。在选择器中选择“在我的 iPhone 上” → “Obsidian”。"; @@ -206,7 +191,7 @@ "Reconnect Obsidian Folder" = "重新连接 Obsidian 文件夹"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "重新连接 Obsidian 访问权限,或在主机设备上调整文件夹权限。"; "Reconnect devices or add missing peers to restore continuous sync." = "重新连接设备或添加缺失的对端,以恢复持续同步。"; -"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "在 VaultSync 中重新连接 Obsidian 文件夹访问权限,然后执行一次前台重新扫描。"; +"Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version." = "在 VaultSync 中重新连接 Obsidian 文件夹访问权限,然后检查受影响的 Vault。可接收内容的 Vault 在此版本中仍保持只读。"; "Recreate or reselect the folder, then trigger a rescan." = "重新创建或重新选择文件夹,然后触发重新扫描。"; "Registered" = "已注册"; "Relay Backend" = "Relay 后端"; @@ -235,19 +220,16 @@ "Remove and re-share the folder from your desktop device." = "从桌面设备中移除该文件夹并重新共享。"; "Removed line. %@" = "已删除行。%@"; "Rename Failed" = "重命名失败"; -"Rename the existing copy in Files, then try Keep Both again." = "请在“文件”中重命名现有副本,然后再次尝试保留两者。"; -"Resolve this conflict manually in Files without replacing either file." = "请在“文件”中手动解决此冲突,不要替换任何文件。"; +"Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available." = "请保持两个副本不变。在单独验证的恢复方案可用之前,请勿重复此操作。"; "Renews" = "续订"; "Requesting camera access…" = "正在请求相机权限…"; "Rescan Failed" = "重新扫描失败"; "Rescan Failed Vaults" = "重新扫描失败的 Vault"; "Rescan Vault" = "重新扫描 Vault"; "Rescan failed vaults, then verify folder access and permissions." = "重新扫描失败的 Vault,然后检查文件夹访问和权限。"; -"Resolve Conflict" = "解决冲突"; -"Resolve Conflicts" = "解决冲突"; "Restore Purchases" = "恢复购买"; "Restore Share" = "恢复共享"; -"A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "当同一个文件同时在两台设备上被编辑时,就会发生冲突。Syncthing 会保存两个版本以防止数据丢失。"; +"A conflict can happen when a file changes on two devices at the same time. Review every visible copy, because automatic retention cannot guarantee that every version will remain available." = "当同一文件同时在两台设备上发生更改时,可能会出现冲突。请检查每个可见副本,因为自动保留无法保证所有版本都会继续可用。"; "APNs Registration" = "APNs 注册"; "APNs Token" = "APNs 令牌"; "Context: %@ · %@" = "上下文:%@ · %@"; @@ -256,7 +238,6 @@ "Present" = "存在"; "Purchase Failed" = "购买失败"; "Rescan All Vaults" = "重新扫描所有 Vault"; -"Review and Accept" = "查看并接受"; "Selects this plan." = "选择此方案。"; "Something Went Wrong" = "出了点问题"; "Sync Conflicts" = "同步冲突"; @@ -279,8 +260,6 @@ "Scanning completed in %@" = "%@ 中的扫描已完成"; "Scanning started in %@" = "%@ 中开始扫描"; "Settings" = "设置"; -"Share a folder from your desktop Syncthing — it will be accepted automatically." = "从桌面 Syncthing 共享一个文件夹——它会被自动接受。"; -"Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "共享请求显示在下方,但在连接 Obsidian 文件夹之前,“接受”和“重试”按钮会被禁用。"; "Shared" = "已共享"; "Shared With" = "共享给"; "Shared by an unknown device" = "由未知设备共享"; @@ -318,8 +297,6 @@ "Terms of Use" = "使用条款"; "The device will be disconnected and removed from all shared folders." = "该设备将被断开连接,并从所有共享文件夹中移除。"; "The embedded Syncthing bridge did not start for a background sync." = "嵌入式 Syncthing bridge 未能为后台同步启动。"; -"The file '%@' was kept as your local version. The other device's version was discarded." = "文件“%@”已保留为你的本地版本。另一台设备的版本已被丢弃。"; -"The file '%@' was overwritten with the version from the other device." = "文件“%@”已被另一台设备的版本覆盖。"; "The folder path no longer exists%@." = "该文件夹路径已不存在%@。"; "The folder scan finished successfully." = "文件夹扫描已成功完成。"; "The sync engine stopped unexpectedly." = "同步引擎意外停止。"; @@ -359,7 +336,6 @@ "VaultSync does not have the required permission for this action." = "VaultSync 没有所需的权限来执行此操作。"; "VaultSync found a configuration problem." = "VaultSync 检测到配置问题。"; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync 需要相机权限来扫描 Syncthing 设备 ID 的二维码。请在设置中启用。"; -"VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync 需要一次性访问你的 Obsidian 文件夹后,才能接受共享。"; "VaultSync reported an unexpected error." = "VaultSync 报告了一个意外错误。"; "Wait a moment and retry." = "请稍等片刻后重试。"; "What is a conflict?" = "什么是冲突?"; @@ -370,9 +346,9 @@ "hours" = "小时"; "No Activity Yet" = "还没有活动"; "Sync engine running" = "同步引擎正在运行"; -"onboarding.welcome.title" = "你的 Obsidian 笔记,私密同步。"; -"onboarding.welcome.subtitle" = "在你自己的设备之间同步 Vault,无需依赖第三方云存储。"; -"onboarding.welcome.benefit.private" = "只在你的设备之间同步"; +"onboarding.welcome.title" = "你的 Obsidian 设置,以隐私为本。"; +"onboarding.welcome.subtitle" = "连接 Obsidian 和你自己的设备。此版本可以查看新的共享 Vault 邀请,但无法接受。"; +"onboarding.welcome.benefit.private" = "在你的设备之间保持私密"; "onboarding.welcome.benefit.obsidian" = "专为 Obsidian Vault 打造"; "onboarding.welcome.benefit.noCloud" = "无需云账号"; "onboarding.cta.continue" = "继续"; @@ -388,18 +364,14 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync 无法访问你的本地 Obsidian 文件夹。"; "Connect your Obsidian folder from the VaultSync home screen." = "请在 VaultSync 主屏幕连接你的 Obsidian 文件夹。"; "Computer or server added" = "已添加电脑或服务器"; -"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "首次同步已完成。Cloud Relay 会在笔记发生变化时立即发送唤醒信号唤醒这部 iPhone——即使应用已关闭。"; +"Cloud Relay can wake this iPhone for background checks. Send Only vaults can upload local changes." = "Cloud Relay 可以唤醒这部 iPhone 进行后台检查。仅发送 Vault 可以上传本地更改。"; "Your iPhone is paired with at least one Syncthing device." = "你的 iPhone 已与至少一台 Syncthing 设备配对。"; "Your iPhone is not paired with a Syncthing device yet." = "你的 iPhone 尚未与 Syncthing 设备配对。"; "Add your computer or server from the Devices section on the home screen." = "请在主屏幕的“设备”部分添加你的电脑或服务器。"; -"Vault syncing" = "Vault 正在同步"; +"Vault setup" = "Vault 设置"; "At least one Obsidian vault is active in VaultSync." = "至少有一个 Obsidian Vault 已在 VaultSync 中激活。"; -"A vault offer is waiting to be accepted." = "有一个 Vault 共享邀请正等待接受。"; -"A vault offer is waiting. Accept it from Pending Shares on the home screen." = "有一个 Vault 邀请正在等待处理。请在主屏幕的“待处理共享”中接受它。"; -"A vault offer was seen earlier, but no vault is syncing right now." = "之前检测到过 Vault 共享邀请,但目前没有 Vault 正在同步。"; -"If syncing has not started, share your Obsidian vault again from Syncthing on your computer." = "如果同步尚未开始,请在电脑上的 Syncthing 中重新共享你的 Obsidian Vault。"; +"A vault offer was seen earlier, but no vault is configured right now." = "之前检测到过 Vault 共享邀请,但目前未配置任何 Vault。"; "No Obsidian vault is active in VaultSync yet." = "目前还没有 Obsidian Vault 在 VaultSync 中激活。"; -"Share your Obsidian vault from Syncthing on your computer." = "请在电脑上的 Syncthing 中共享你的 Obsidian Vault。"; "VaultSync’s sync engine is running." = "VaultSync 的同步引擎正在运行。"; "VaultSync’s sync engine is still starting or unavailable." = "VaultSync 的同步引擎仍在启动或当前不可用。"; "If this stays unavailable, restart VaultSync and check the home screen for issues." = "如果此状态持续不可用,请重启 VaultSync 并在主屏幕检查问题。"; @@ -415,12 +387,6 @@ "Skip these on this iPhone? You can change this anytime in Sync Filters." = "在此 iPhone 上跳过这些?你可以随时在同步过滤器中修改。"; "Skip" = "跳过"; "Choose what gets synced to this iPhone" = "选择要同步到此 iPhone 的内容"; -"Always skip on this iPhone" = "在此 iPhone 上始终跳过"; -"More actions" = "更多操作"; -"Skipping enabled" = "跳过已启用"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "“%@”及其冲突副本将不再同步到此 iPhone。你可以在同步过滤器中撤销。"; -"1 existing conflict copy was removed." = "已移除 1 个现有冲突副本。"; -"%d existing conflict copies were removed." = "已移除 %d 个现有冲突副本。"; "+ conflict copies" = "+ 冲突副本"; "Could not add filter" = "无法添加过滤器"; "Could not save filters" = "无法保存过滤器"; @@ -450,17 +416,17 @@ "%d Required Devices Are Disconnected" = "%d 台必需设备已断开连接"; "1 Pending Share Needs Attention" = "1 个待处理共享需要处理"; "%d Pending Shares Need Attention" = "%d 个待处理共享需要处理"; -"1 Conflict Needs Resolution" = "1 个冲突待解决"; -"%d Conflicts Need Resolution" = "%d 个冲突待解决"; +"1 Conflict Available for Review" = "有 1 个冲突可供检查"; +"%d Conflicts Available for Review" = "有 %d 个冲突可供检查"; /* Issue #10 — conflict notification body */ -"1 file has a sync conflict. Open VaultSync to resolve it." = "1 个文件存在同步冲突。打开 VaultSync 解决。"; -"%d files have sync conflicts. Open VaultSync to resolve them." = "%d 个文件存在同步冲突。打开 VaultSync 解决。"; +"1 file has a sync conflict. Open VaultSync to see which copies are still available." = "1 个文件存在同步冲突。请打开 VaultSync 查看哪些副本仍然可用。"; +"%d files have sync conflicts. Open VaultSync to see which copies are still available." = "%d 个文件存在同步冲突。请打开 VaultSync 查看哪些副本仍然可用。"; /* Issue #10 — conflict-notifications toggle (Settings) */ "Notifications" = "通知"; "Conflict Notifications" = "冲突通知"; -"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "检测到同步冲突时显示提醒横幅。关闭此项不会影响 Cloud Relay 或后台同步——你的 Vault 会继续同步。"; +"Show a banner when conflict copies are detected. Turning this off does not affect Cloud Relay, background checks, or Send Only uploads." = "检测到冲突副本时显示横幅。关闭此项不会影响 Cloud Relay、后台检查或仅发送 Vault 的上传。"; /* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ "Alert Banners" = "提醒横幅"; @@ -472,7 +438,7 @@ "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs 注册失败。请检查网络连接并重试注册。静默推送无需启用提醒横幅。"; /* Issue #10 — background-sync reliability (error-settled outcome) */ -"Background sync settled with at least one folder in an error state." = "后台同步已结束——至少一个文件夹处于错误状态。"; +"Background sync stopped because the engine or a folder reported a safety issue." = "后台同步已停止,因为引擎或文件夹报告了安全问题。"; /* Issue #10 — relay reachable (vs delivering) */ "Cloud Relay looks reachable" = "Cloud Relay 似乎可达"; @@ -508,18 +474,12 @@ "Remove this device?" = "移除此设备?"; "Double-tap to share this vault with this device." = "双击以与此设备共享此 Vault。"; "Double-tap to stop sharing this vault with this device." = "双击以停止与此设备共享此 Vault。"; -"All conflicts resolved" = "所有冲突已解决"; +"No conflicts found" = "未发现冲突"; "Loading files…" = "正在加载文件…"; "Computing diff…" = "正在计算差异…"; -"Other Device" = "其他设备"; -"Other Device (%@)" = "其他设备(%@)"; -"Added lines come from the other device; removed lines are your version on this device." = "添加的行来自其他设备;删除的行是此设备上你的版本。"; -"(empty or unreadable)" = "(为空或无法读取)"; -"a new name" = "一个新名称"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "已保留两个版本。\n\n你的本地版本保留为“%@”。\n其他设备的版本已重命名为“%@”。"; "%d conflicts" = "%d 个冲突"; "Your contribution is pending approval." = "你的支持正在等待批准。"; -"iOS did not provide a push token required for instant sync." = "iOS 未提供即时同步所需的推送令牌。"; +"iOS did not provide a push token required for Cloud Relay wake-ups." = "iOS 未提供 Cloud Relay 唤醒信号所需的推送令牌。"; "%@ (Trigger: %@)" = "%@(触发:%@)"; "1 additional file synced in %@" = "在 %@ 中同步了 1 个额外文件"; "%d additional files synced in %@" = "在 %2$@ 中同步了 %1$d 个额外文件"; @@ -535,7 +495,6 @@ "No folders were available after forced silent-push restart." = "强制静默推送重启后没有可用的文件夹。"; "No folders were available for background sync." = "没有可用于后台同步的文件夹。"; "No security-scoped bookmark access was available." = "没有可用的安全范围书签访问权限。"; -"Accept or create a shared vault before relying on background sync." = "在依赖后台同步前,请接受或创建一个共享 Vault。"; "Retry from the app and review relay/background diagnostics in Settings." = "请在应用中重试,并在设置中查看 Relay/后台诊断。"; "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "静默推送已重启 Syncthing,但在应用返回空闲前未观察到实际同步进展。"; "Sync did not reach idle before %ds deadline." = "同步未在 %d 秒截止时间前进入空闲状态。"; @@ -548,12 +507,12 @@ "relay network: %@" = "Relay 网络:%@"; /* Cloud Relay activation (2026-05-31): honest server-setup + conversion */ -"Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync." = "Cloud Relay 未启用。没有它,传入的改动会在你打开 VaultSync 时到达。"; -"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "如果希望改动在发生的那一刻就推送过来,请在 Relay 标签页中启用 Cloud Relay。"; +"Cloud Relay is not enabled. Open VaultSync to review current status and conflict copies." = "Cloud Relay 未启用。打开 VaultSync 以检查当前状态和冲突副本。"; +"Enable Cloud Relay on the Relay tab to wake VaultSync for background checks." = "在 Relay 标签页中启用 Cloud Relay,以唤醒 VaultSync 进行后台检查。"; "Cloud Relay — finish server setup" = "Cloud Relay — 完成服务器设置"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "你已订阅,但尚未收到最近的唤醒信号。请确认 vaultsync-notify 助手正在你的服务器上运行。"; "Set up the server helper from the Relay tab → Set Up Your Server." = "在 Relay 标签页 →“设置你的服务器”中配置服务器助手。"; -"Wake-ups are being delivered — incoming changes sync the moment they happen." = "唤醒信号正在送达——传入的改动会在发生的那一刻同步。"; +"Wake-ups are being delivered. VaultSync can check status in the background, and Send Only vaults can upload local changes." = "唤醒信号正在送达。VaultSync 可以在后台检查状态,仅发送 Vault 可以上传本地更改。"; "Your server helper is running — wake-ups are being delivered." = "你的服务器助手正在运行——唤醒信号正在送达。"; "Why this step" = "为什么需要这一步"; "Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay 需要在你的服务器上运行一个小助手。它监视 Syncthing 的改动并向 VaultSync 发送唤醒信号——它从不查看你的笔记。没有它,订阅就无法唤醒应用。"; @@ -570,7 +529,7 @@ "Last wake-up" = "上次唤醒"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "自动续订,直至取消。可随时在“设置 → 订阅”中取消。"; "Cancel anytime in Settings → Subscriptions" = "可随时在“设置 → 订阅”中取消"; -"Get instant updates" = "获取即时更新"; +"Enable background wake-ups" = "启用后台唤醒"; "Turn on Cloud Relay" = "启用 Cloud Relay"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ @@ -581,35 +540,28 @@ "Add your server first" = "请先添加你的服务器"; "Best value" = "最划算"; "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe." = "Cloud Relay 会唤醒指定的设备。请先在“设备”标签页中配对承载你 Vault 的电脑或服务器,然后再回来订阅。"; -"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "直接在这里完成这些步骤。随着你的进展,它们会亮起绿色——你也可以稍后随时从主屏幕完成它们。"; "Connect your Obsidian folder" = "连接你的 Obsidian 文件夹"; "Double tap to copy" = "双击以复制"; -"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "授予 VaultSync 对本地 Obsidian 文件夹的一次性访问权限,以便同步你的笔记。"; "How is this private?" = "这如何保护隐私?"; -"Instant sync, still private" = "即时同步,依然私密"; -"Let’s get your vault synced" = "来同步你的 Vault 吧"; +"Private background wake-ups" = "私密的后台唤醒"; "Loading plans…" = "正在加载方案…"; "Manage" = "管理"; "Monthly" = "按月"; "One step left to activate" = "还差一步即可激活"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "可选:之后可启用 Cloud Relay 以获得即时更新——你可以在 Relay 标签页中找到它。"; +"Optional: turn on Cloud Relay later for background wake-ups — you’ll find it on the Relay tab." = "可选:之后可启用 Cloud Relay 以接收后台唤醒信号——你可以在 Relay 标签页中找到它。"; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "通过设备 ID 或二维码,将此 iPhone 与承载你 Vault 的 Syncthing 设备配对。"; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay 健康状况与诊断"; -"One step left: run a single line on your server and instant updates start. The helper only sends a wake-up — it never sees your notes." = "只差一步:在你的服务器上运行一行命令,即时更新就会开始。助手只发送唤醒信号——绝不会看到你的笔记。"; +"One step left: run one command on your server to enable background wake-ups. The helper never sees your notes." = "只差一步:在你的服务器上运行一条命令以启用后台唤醒信号。助手绝不会看到你的笔记。"; "Save %d%%" = "节省 %d%%"; "Server helper setup" = "服务器助手设置"; "Set up the server helper" = "设置服务器助手"; -"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "在你的电脑上通过 Syncthing 共享你的 Obsidian Vault。VaultSync 会自动接受——它一到达就会变绿。"; "Starts a subscription purchase." = "开始订阅购买。"; "Sync" = "同步"; -"Sync your first vault" = "同步你的第一个 Vault"; -"Wake-ups are being delivered — changes from your other devices arrive instantly." = "唤醒信号正在送达——来自你其他设备的更改会即时到达。"; "Yearly" = "按年"; -"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "保留你的本地版本,另一台设备的版本会以新名称添加。不会丢弃任何内容。"; -"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "你的笔记绝不会经过我们的服务器。Cloud Relay 只发送一个微小的唤醒信号,让来自你其他设备的更改在发生的那一刻就送达——即使应用已关闭。"; +"Your notes never touch our servers. Cloud Relay sends only a wake-up so VaultSync can check status in the background. Send Only vaults can upload local changes." = "你的笔记绝不会经过我们的服务器。Cloud Relay 只发送唤醒信号,让 VaultSync 可以在后台检查状态。仅发送 Vault 可以上传本地更改。"; "One-step setup: a single line on your server." = "一步设置:只需在服务器上运行一行命令。"; -"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "你的 Vault 本身就已经免费、点对点地同步。Relay 只是免去了“打开应用才能同步”的等待——它不是云存储。"; +"VaultSync remains peer-to-peer. Relay provides background wake-ups; it is not cloud storage and never carries your notes." = "VaultSync 始终采用点对点方式。Relay 提供后台唤醒信号;它不是云存储,也绝不会传输你的笔记。"; "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "你已订阅,但尚未收到唤醒信号。只有当助手在你的服务器上运行时,Cloud Relay 才会送达。"; /* Folder path resilience (#25) */ @@ -629,7 +581,6 @@ "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "你已订阅,但你的服务器从未唤醒过此 iPhone。一步即可完成设置。"; "Opens Cloud Relay setup." = "打开 Cloud Relay 设置。"; "Cloud Relay went quiet" = "Cloud Relay 暂无动静"; -"Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "你的服务器刚刚联系上了此 iPhone。现在,传入的改动会在发生的那一刻同步。"; "Great" = "太好了"; "Not active yet" = "尚未激活"; "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "你已订阅。当助手在你常开的电脑或服务器上运行后,唤醒信号即会开始送达——请在下方完成设置。"; @@ -646,7 +597,7 @@ /* Manual conflict review — notes and .obsidian state (Settings) */ "Review Conflicts Manually" = "手动检查冲突"; -"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. Review each conflict and decide what to keep." = "VaultSync 不会自动在笔记、Obsidian 设置或插件状态的冲突副本之间做出选择。请逐一检查冲突,并决定要保留的内容。"; +"VaultSync does not automatically choose between conflicting copies in your notes, Obsidian settings, or plugin state. You can inspect each conflict, but recovery actions are unavailable in this version." = "VaultSync 不会自动在笔记、Obsidian 设置或插件状态的冲突副本之间做出选择。你可以检查每个冲突,但此版本无法执行恢复操作。"; /* Path collision migration shield (#45) — vaults an older version already merged onto one folder */ "Two Vaults Are Sharing One Folder" = "两个 Vault 共用同一个文件夹"; @@ -667,9 +618,7 @@ "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "文件夹“%@”已包含文件。接受此共享会将其内容与共享的 Vault 合并,并将结果同步到其他设备。"; "Vault Folder Was Moved or Deleted" = "Vault 文件夹已被移动或删除"; "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync 已无法确认此文件夹仍包含该 Vault 的数据%@——该文件夹可能在 VaultSync 之外被移动、重命名、替换或删除。同步已停止,以保护你的笔记。"; -"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "如果你移动或重命名了该文件夹,请将它移回原来的位置。如果它已不存在,请在此 iPhone 上移除该 Vault,然后在“待处理共享”中重新接受它。VaultSync 绝不会自行移动、重新创建或删除文件夹。"; "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "请按照受影响 Vault 旁显示的恢复步骤操作——重新扫描无法修复已被移动或删除的 Vault 文件夹。"; -"Offer “%@” received — accepting…" = "已收到“%@”的共享邀请——正在接受…"; "Offer “%@” received — connect your Obsidian folder first." = "已收到“%@”的共享邀请——请先连接你的 Obsidian 文件夹。"; "Offer “%@” needs your attention. Tap “Finish Setup Later” below to review it on the home screen." = "共享邀请“%@”需要你处理。点按下方“稍后完成设置”,在主屏幕上查看。"; @@ -688,10 +637,8 @@ "Waiting for first sync" = "等待首次同步"; /* Onboarding guidance polish (#95) */ -"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. Then share your vault to start syncing." = "设备已添加。现在请在电脑上的 Syncthing 中确认这台 iPhone——那里会出现一个确认请求。然后共享你的 Vault 即可开始同步。"; "The folder you selected is stored in iCloud Drive. iCloud can keep files as placeholders that are not fully downloaded on this iPhone, which can stall syncing and create conflicts. For reliable syncing, use your vaults under \"On My iPhone\" → \"Obsidian\" and select that folder instead." = "你选择的文件夹存储在 iCloud 云盘中。iCloud 可能将文件保留为未完全下载到这台 iPhone 的占位文件,这会使同步停滞并产生冲突。为了可靠同步,请将你的 Vault 放在“在我的 iPhone 上” → “Obsidian”中,并改为选择该文件夹。"; "Opens the setup checklist." = "打开设置检查清单。"; -"A vault offer was ignored on this iPhone, so it is not accepted automatically." = "此 iPhone 上忽略了一个 Vault 共享邀请,因此不会自动接受。"; "Open \"Ignored shares\" under Pending Shares on the home screen and tap \"Restore Share\". Sharing again from your computer will not create a new offer." = "在主屏幕的待处理共享中打开“已忽略的共享”,然后点按“恢复共享”。在电脑上重新共享不会产生新的邀请。"; "Camera Unavailable" = "相机不可用"; "The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID." = "无法在此设备上启动相机。请改为手动输入设备 ID——在电脑上的 Syncthing 中选择“操作” → “显示 ID”。"; @@ -940,3 +887,52 @@ "Upload check rate limited — upload unobserved" = "上传检查受到速率限制 — 未观察到上传"; "Upload check unsupported for this exact folder and peer" = "此精确文件夹和对等设备不支持上传检查"; "Upload capability unavailable — no upload evidence" = "上传能力不可用 — 没有上传证据"; +"Upload and download checks are unavailable while receive-side changes are disabled. Pairing details remain available for review." = "接收端更改停用期间,上传和下载检查不可用。配对详情仍可供查看。"; +"Conflict Safety Review Required" = "需要进行冲突安全检查"; +"Conflict Safety Status Unavailable" = "冲突安全状态不可用"; +"Background Sync Stopped for Safety" = "后台同步已为安全起见停止"; +"Open VaultSync to review the safety issue. Leave conflict copies unchanged while safety recovery is unavailable." = "请打开 VaultSync 检查安全问题。在安全恢复方案不可用时,请保持冲突副本不变。"; +"Conflict Safety Stop" = "冲突安全停止"; +"Checking Conflict Safety" = "正在检查冲突安全性"; +"Conflict Recovery Unavailable" = "冲突恢复不可用"; +"Conflict recovery actions are not available in this version." = "此版本不提供冲突恢复操作。"; +"Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version." = "请在此检查仍然可用的副本。请保持文件不变;此版本的 VaultSync 无法执行恢复操作。"; +"Conflict Details" = "冲突详情"; +"Review Conflicts" = "检查冲突"; +"VaultSync keeps receive-capable vaults read-only in this version." = "VaultSync 在此版本中将可接收内容的 Vault 保持为只读状态。"; +"You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable." = "你可以查看可用状态和冲突副本,但接收端更改和冲突恢复不可用。"; +"If you moved or renamed the folder, move it back to its original place. If it is gone, keep this vault stopped and preserve all remaining copies. New share acceptance is unavailable in this version. VaultSync never moves, recreates, or deletes folders on its own." = "如果你移动或重命名了文件夹,请将其移回原位。如果文件夹已不存在,请让此 Vault 保持停止状态并保留所有剩余副本。此版本无法接受新的共享。VaultSync 绝不会自行移动、重新创建或删除文件夹。"; +"Keep the affected vaults paused and preserve every remaining copy. New share acceptance is unavailable in this version." = "请让受影响的 Vault 保持暂停状态,并保留每一份剩余副本。此版本无法接受新的共享。"; +"A vault offer is available for inspection." = "有一个 Vault 邀请可供查看。"; +"Open Pending Shares to inspect the offer details. This version cannot accept it." = "打开“待处理共享”以查看邀请详情。此版本无法接受它。"; +"An ignored vault offer remains stored on this iPhone." = "已忽略的 Vault 邀请仍保存在此 iPhone 上。"; +"Open Pending Shares to inspect its details. No action is available in this version." = "打开“待处理共享”以查看其详情。此版本不提供任何操作。"; +"New share acceptance is unavailable in this version." = "此版本无法接受新的共享。"; +"New shared vault offers can be inspected, but not accepted in this version." = "此版本可以查看新的共享 Vault 邀请,但无法接受。"; +"Device added. Now confirm this iPhone in Syncthing on your computer — a confirmation prompt appears there. New shared vault offers are inspection-only in this version." = "设备已添加。现在请在电脑上的 Syncthing 中确认此 iPhone——确认提示会显示在那里。此版本中的新共享 Vault 邀请仅供查看。"; +"VaultSync needs one-time access to your Obsidian folder to inspect local vaults and existing sync status." = "VaultSync 需要一次性访问你的 Obsidian 文件夹,以查看本地 Vault 和现有同步状态。"; +"Not syncing in this version — new share offers are inspection-only." = "此版本中未同步——新的共享邀请仅供查看。"; +"Set up VaultSync" = "设置 VaultSync"; +"Connect Obsidian and a device here. New shared vault offers are inspection-only in this version." = "在此连接 Obsidian 和一台设备。此版本中的新共享 Vault 邀请仅供查看。"; +"Offer “%@” is available for inspection only. This version cannot accept it." = "邀请“%@”仅供查看。此版本无法接受它。"; +"Pending shares are read-only in this version." = "此版本中的待处理共享为只读。"; +"You can inspect who shared each offer, but no action is available." = "你可以查看每个邀请的共享来源,但没有可用操作。"; +"Read Only" = "只读"; +"No Vaults Syncing" = "没有 Vault 正在同步"; +"Conflict Inspection Unavailable" = "冲突检查不可用"; +"VaultSync cannot verify whether the conflict list is complete." = "VaultSync 无法验证冲突列表是否完整。"; +"Open conflicts to review any previously visible copies. No recovery action is available." = "打开冲突以检查先前可见的副本。恢复操作不可用。"; +"Current File" = "当前文件"; +"Conflict Copy" = "冲突副本"; +"Added lines are from the conflict copy; removed lines are from the current file." = "添加的行来自冲突副本;删除的行来自当前文件。"; +"(empty)" = "(空)"; +"This copy is unavailable for inspection." = "此副本无法检查。"; +"Conflict inspection is unavailable." = "冲突检查不可用。"; +"VaultSync cannot verify whether the conflict list is complete. Previously visible copies remain shown for review." = "VaultSync 无法验证冲突列表是否完整。先前可见的副本仍会保留以供检查。"; +"Vault Folder Needs Manual Recovery" = "Vault 文件夹需要手动恢复"; +"VaultSync can no longer verify the configured vault folder." = "VaultSync 无法再验证已配置的 Vault 文件夹。"; +"Keep this vault stopped and preserve every remaining copy. Restore the original folder at its original location; VaultSync will not move or re-point it automatically." = "请保持此 Vault 停止并保留所有剩余副本。将原始文件夹恢复到原始位置;VaultSync 不会自动移动或重新指向它。"; +"Vault configured" = "Vault 已配置"; +"At least one Send Only vault can continue uploading local changes." = "至少一个仅发送 Vault 可以继续上传本地更改。"; +"Existing receive-capable vaults are available for review only in this version." = "在此版本中,现有可接收内容的 Vault 仅供检查。"; +"Open VaultSync to inspect device and folder configuration. New share acceptance is unavailable in this version." = "打开 VaultSync 以检查设备和文件夹配置。此版本无法接受新的共享。"; diff --git a/ios/VaultSyncTests/BackgroundSyncReasonTests.swift b/ios/VaultSyncTests/BackgroundSyncReasonTests.swift index a942d67..d421f96 100644 --- a/ios/VaultSyncTests/BackgroundSyncReasonTests.swift +++ b/ios/VaultSyncTests/BackgroundSyncReasonTests.swift @@ -20,6 +20,7 @@ struct BackgroundSyncReasonTests { .bridgeStartFailed, .notIdleBeforeDeadline, .failed, + .settledWithFolderError, ] for result in failures { @@ -31,6 +32,33 @@ struct BackgroundSyncReasonTests { } } + @Test("Safety ItemFinished is never local-data progress (#150)") + func safetyItemIsNotProgress() { + let startedAt = SyncBridgeService.parseBridgeTimestamp("2027-01-15T08:00:00Z")! + var tracker = BackgroundSyncService.SilentPushProgressTracker( + lastEventID: 40, + startedAt: startedAt + ) + tracker.requiresLocalDataProgress = true + + let snapshot = tracker.observe([ + .init( + id: 41, + type: "ItemFinished", + time: "2027-01-15T08:00:01Z", + data: [ + "folder": "redaction-probe-folder", + "type": "file", + "action": "update", + "reason": ConflictSafetyPolicy.stoppedReason, + ] + ), + ]) + + #expect(snapshot.requiresLocalDataProgress) + #expect(!snapshot.sawLocalDataProgress) + } + @Test("Specific reason code copy remains actionable") func specificReasonCodeCopy() { #expect(BackgroundSyncService.SyncResult.noBookmarkAccess.issueTitle == L10n.tr("Background Sync Could Not Access Obsidian")) @@ -39,10 +67,22 @@ struct BackgroundSyncReasonTests { #expect(BackgroundSyncService.SyncResult.notIdleBeforeDeadline.issueTitle == L10n.tr("Background Sync Timed Out")) #expect( BackgroundSyncService.SyncResult.noBookmarkAccess.remediation - == L10n.tr("Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan.") + == L10n.tr("Reconnect your Obsidian folder access in VaultSync, then review the affected vault. Receive-capable vaults remain read-only in this version.") ) } + @Test("Engine safety stop is non-retryable while ordinary start failures remain retryable (#150)") + func engineSafetyStopStartResultIsFailClosed() { + #expect(BackgroundSyncService.syncResultForBridgeStartFailure( + ConflictSafetyPolicy.engineStopMarker + ) == .settledWithFolderError) + #expect(BackgroundSyncService.syncResultForBridgeStartFailure( + "redaction-probe-engine-start-error" + ) == .bridgeStartFailed) + #expect(!BackgroundSyncService.SyncResult.settledWithFolderError.remediation + .lowercased().contains("retry")) + } + @Test("Silent push restart requires fresh local data progress before success") func silentPushRestartRequiresLocalDataProgress() { let startedAt = SyncBridgeService.parseBridgeTimestamp("2027-01-15T08:00:00.100Z")! diff --git a/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift index 8dd63c8..05f2701 100644 --- a/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift +++ b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift @@ -193,6 +193,7 @@ struct DurableIssueFloorTests { #expect(floor([(.conflicts, .warning)]) == .warning) #expect(floor([(.pathCollision, .critical)]) == .critical) #expect(floor([(.nestedFolders, .critical)]) == .critical) + #expect(floor([(.conflictRetentionSafety, .critical)]) == .critical) #expect(floor([(.folderErrors, .critical)]) == .critical) #expect(floor([(.pendingShares, .warning), (.pathCollision, .critical)]) == .critical) } @@ -240,15 +241,35 @@ struct IssueFloorWiringTests { SyncthingManager.FolderInfo( id: "vault-a", label: "Vault A", - path: "/tmp/issuefloor/vault-a", - type: "sendreceive", + path: "/redaction-probe/vault-a", + type: "sendonly", paused: false, deviceIDs: [] ), ]) + manager._testSetFolderStatuses(["vault-a": clearStatus()]) return manager } + private func clearStatus() -> SyncthingManager.FolderStatusInfo { + SyncthingManager.FolderStatusInfo(payload: .init( + state: "idle", + stateChanged: "2026-07-07T10:00:00Z", + completionPct: 100, + globalBytes: 0, + globalFiles: 0, + localBytes: 0, + localFiles: 0, + needBytes: 0, + needFiles: 0, + inProgressBytes: 0, + errorReason: nil, + errorMessage: nil, + errorPath: nil, + errorChanged: nil + )) + } + private func errorStatus() -> SyncthingManager.FolderStatusInfo { SyncthingManager.FolderStatusInfo(payload: .init( state: "error", @@ -297,4 +318,24 @@ struct IssueFloorWiringTests { #expect(manager._testLastWrittenIssueFloor() == WidgetSnapshotStore.IssueFloor.none) #expect(manager._testLastWrittenWidgetSnapshot()?.status == SyncStatus.synced.wireValue) } + + @MainActor + @Test("A receive-capable folder records the critical containment floor (#150)") + func receiveFolderRecordsContainmentFloorIssue150() { + let manager = makeManager(lastSync: Date()) + manager._testSetFolders([ + .init( + id: "vault-a", + label: "Vault A", + path: "/redaction-probe/vault-a", + type: "sendreceive", + paused: false, + deviceIDs: [] + ), + ]) + manager._testWriteWidgetSnapshot() + + #expect(manager._testLastWrittenIssueFloor() == .critical) + #expect(manager._testLastWrittenWidgetSnapshot()?.status == SyncStatus.attention.wireValue) + } } diff --git a/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift b/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift new file mode 100644 index 0000000..26adbc6 --- /dev/null +++ b/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift @@ -0,0 +1,1361 @@ +import Testing +import UIKit +@testable import VaultSync + +private final class Issue150EffectCounter: @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Int] = [:] + + func record(_ key: String) { + lock.lock() + values[key, default: 0] += 1 + lock.unlock() + } + + func value(_ key: String) -> Int { + lock.lock() + defer { lock.unlock() } + return values[key, default: 0] + } +} + +@Suite("Conflict safety stays fail-closed across Swift surfaces (#150)") +struct ConflictRetentionSafetyIntegrationTests { + @MainActor + private func makeManager( + folderIDs: [String] = ["fixture-folder-a"], + folderType: String = "sendreceive" + ) -> SyncthingManager { + let historyDefaults = TestSupport.makeIsolatedDefaults(label: "Issue150History") + let manager = SyncthingManager( + syncHistoryStore: SyncHistoryStore( + defaults: historyDefaults, + storageKey: "issue-150-history" + ) + ) + manager._testSetLastBackgroundSyncOutcome(nil) + manager._testSetFolders(folderIDs.map { + .init( + id: $0, + label: "Fixture Label", + path: "/fixture/path", + type: folderType, + paused: false, + deviceIDs: [] + ) + }) + return manager + } + + private func status( + state: String = "error", + reason: String?, + message: String? = nil, + path: String? = nil + ) -> SyncthingManager.FolderStatusInfo { + .init(payload: .init( + state: state, + stateChanged: "2000-01-01T00:00:00Z", + completionPct: 100, + globalBytes: 200, + globalFiles: 2, + localBytes: 200, + localFiles: 2, + needBytes: 0, + needFiles: 0, + inProgressBytes: 0, + errorReason: reason, + errorMessage: message, + errorPath: path, + errorChanged: "2000-01-01T00:00:00Z" + )) + } + + private func liveStatusJSON( + state: String = "idle", + reason: String? = nil, + message: String? = nil, + path: String? = nil + ) -> String { + let payload = SyncBridgeService.FolderStatusPayload( + state: state, + stateChanged: "2000-01-01T00:00:00Z", + completionPct: 100, + globalBytes: 200, + globalFiles: 2, + localBytes: 200, + localFiles: 2, + needBytes: 0, + needFiles: 0, + inProgressBytes: 0, + errorReason: reason, + errorMessage: message, + errorPath: path, + errorChanged: nil + ) + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) else { + return "" + } + return json + } + + private func folder( + _ id: String, + type: String = "sendreceive" + ) -> SyncthingManager.FolderInfo { + .init( + id: id, + label: "Fixture Label", + path: "/fixture/path", + type: type, + paused: false, + deviceIDs: [] + ) + } + + private var conflict: SyncthingManager.ConflictInfo { + .init( + originalPath: "fixture-note.md", + conflictPath: "fixture-note.sync-conflict-20000101-000000-FIXTURE.md", + conflictDate: "20000101-000000", + deviceShortID: "FIXTURE" + ) + } + + @Test("Stopped and unknown live status create only a critical read-only issue (#150)") + @MainActor + func stoppedAndUnknownAreDedicatedIssues() { + let manager = makeManager(folderIDs: ["stopped", "unknown"]) + manager._testSetFolderStatuses([ + "stopped": status(state: "idle", reason: ConflictSafetyPolicy.stoppedReason), + "unknown": status( + state: "idle", + reason: ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason + ), + ]) + + let issues = manager.unresolvedIssues.filter { $0.kind == .conflictRetentionSafety } + #expect(issues.count == 2) + #expect(issues.allSatisfy { $0.severity == .critical }) + #expect(!manager.unresolvedIssues.contains { $0.kind == .folderErrors }) + #expect(!manager.hasRescanableFolderErrors) + #expect(manager.folderUserError(folderID: "stopped")?.technicalDetails == nil) + #expect(manager.folderUserError(folderID: "unknown")?.technicalDetails == nil) + #expect(issues.allSatisfy { !$0.remediation.localizedCaseInsensitiveContains("Keep Both") }) + #expect(issues.allSatisfy { !$0.remediation.localizedCaseInsensitiveContains("retry") }) + } + + @Test("Normal SendOnly diagnostics stay outside conflict safety gates (#150)") + @MainActor + func sendOnlyDiagnosticsRetainTheirExistingSemanticsIssue150() { + let diagnosticCases: [( + status: SyncthingManager.FolderStatusInfo, + category: SyncUserErrorCategory, + isRescanable: Bool, + isUnreachable: Bool + )] = [ + ( + status: status( + reason: "permission_denied", + message: "fixture permission failure", + path: "/fixture/path" + ), + category: .permission, + isRescanable: true, + isUnreachable: true + ), + ( + status: status( + reason: "unknown_error", + message: "fixture folder marker missing" + ), + category: .folderMarkerMissing, + isRescanable: false, + isUnreachable: false + ), + ( + status: status(reason: "disk_full", message: "fixture disk failure"), + category: .config, + isRescanable: true, + isUnreachable: false + ), + ( + status: status(reason: "unknown_error"), + category: .config, + isRescanable: true, + isUnreachable: false + ), + ( + status: status(reason: nil, message: "fixture raw detail"), + category: .config, + isRescanable: true, + isUnreachable: false + ), + ] + + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "sendonly", + status: nil + ) == .clear) + + let missingStatusManager = makeManager(folderType: "sendonly") + #expect(missingStatusManager.conflictSafetyState(folderID: "fixture-folder-a") == .clear) + #expect(missingStatusManager.conflictSafetyBlockedFolderIDs.isEmpty) + + for diagnostic in diagnosticCases { + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "sendonly", + status: diagnostic.status + ) == .clear) + + let manager = makeManager(folderType: "sendonly") + manager._testSetFolderStatuses(["fixture-folder-a": diagnostic.status]) + + let safetyState = manager.conflictSafetyState(folderID: "fixture-folder-a") + #expect(safetyState == .clear) + #expect(ConflictSafetyPolicy.allowsMutation(for: safetyState)) + #expect(manager.conflictSafetyBlockedFolderIDs.isEmpty) + #expect(!manager.unresolvedIssues.contains { $0.kind == .conflictRetentionSafety }) + #expect(manager.unresolvedIssues.contains { $0.kind == .folderErrors } + == !diagnostic.isUnreachable) + #expect(manager.folderUserError(folderID: "fixture-folder-a")?.category + == diagnostic.category) + #expect(manager.hasRescanableFolderErrors == diagnostic.isRescanable) + #expect(manager.unreachableFolders.contains { $0.id == "fixture-folder-a" } + == diagnostic.isUnreachable) + } + } + + @Test("Fixed conflict safety codes remain global for SendOnly folders (#150)") + @MainActor + func sendOnlyFixedSafetyCodesRemainBlockedAndSanitizedIssue150() { + for (reason, expectedState) in [ + (ConflictSafetyPolicy.stoppedReason, ConflictSafetyPolicy.State.stopped), + ( + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ConflictSafetyPolicy.State.unknown + ), + ( + ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason, + ConflictSafetyPolicy.State.unknown + ), + ] { + let fixedStatus = status( + state: "idle", + reason: reason, + message: "redaction-probe-detail", + path: "redaction-probe/path" + ) + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "sendonly", + status: fixedStatus + ) == expectedState) + + let manager = makeManager(folderType: "sendonly") + manager._testSetFolderStatuses(["fixture-folder-a": fixedStatus]) + + #expect(manager.conflictSafetyState(folderID: "fixture-folder-a") == expectedState) + #expect(manager.conflictSafetyBlockedFolderIDs == ["fixture-folder-a"]) + #expect(manager.unresolvedIssues.contains { + $0.kind == .conflictRetentionSafety && $0.folderID == "fixture-folder-a" + }) + #expect(!manager.unresolvedIssues.contains { $0.kind == .folderErrors }) + #expect(manager.folderUserError(folderID: "fixture-folder-a")?.category + == .conflictRetentionSafetyStop) + #expect(manager.folderUserError(folderID: "fixture-folder-a")?.technicalDetails == nil) + } + } + + @Test("Every conflict recovery is unavailable while rescans keep their safety gate (#150)") + @MainActor + func directMutationGatesStopAndUnknown() { + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason, + ] { + let manager = makeManager(folderType: "sendonly") + manager._testSetFolderStatuses([ + "fixture-folder-a": status(state: "idle", reason: reason), + ]) + manager._testSetConflictFiles(["fixture-folder-a": [conflict]]) + + #expect(manager.rescanFolder(id: "fixture-folder-a") + == ConflictSafetyPolicy.engineStopMarker) + #expect(manager.resolveConflict( + folderID: "fixture-folder-a", + conflictFileName: conflict.conflictPath, + keepConflict: false + ) == "vaultsync-conflict-recovery-unavailable") + let keepBoth = manager.keepBothConflict(folderID: "fixture-folder-a", conflict: conflict) + #expect(keepBoth.error == "vaultsync-conflict-recovery-unavailable") + #expect(keepBoth.newPath == nil) + let skip = manager.skipFileAndCleanupConflicts( + folderID: "fixture-folder-a", + originalPath: conflict.originalPath + ) + #expect(skip.error?.category == .conflictRetentionSafetyStop) + #expect(skip.removedConflicts == 0) + #expect(manager.conflictFiles["fixture-folder-a"]?.map(\.conflictPath) + == [conflict.conflictPath]) + } + + let missingStatus = makeManager() + #expect(missingStatus.resolveConflict( + folderID: "fixture-folder-a", + conflictFileName: conflict.conflictPath, + keepConflict: true + ) == "vaultsync-conflict-recovery-unavailable") + } + + @Test("Only fixed safety evidence blocks normal SendOnly controls (#150)") + func sendOnlyControlGateUsesFixedEvidenceIssue150() { + let cachedClear = status(state: "idle", reason: nil) + let liveClear = liveStatusJSON() + let normalLiveErrors = [ + liveStatusJSON( + state: "error", + reason: "permission_denied", + message: "fixture permission failure", + path: "/fixture/path" + ), + liveStatusJSON( + state: "error", + reason: "unknown_error", + message: "fixture folder marker missing" + ), + liveStatusJSON( + state: "error", + reason: "disk_full", + message: "fixture disk failure" + ), + liveStatusJSON(state: "error", reason: "unknown_error"), + liveStatusJSON(state: "error", reason: nil, message: "fixture raw detail"), + ] + + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason, + ] { + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedClear, + liveStatusJSON: liveStatusJSON(reason: reason) + ) == ConflictSafetyPolicy.engineStopMarker) + } + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedClear, + liveStatusJSON: "{}" + ) == nil) + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedClear, + liveStatusJSON: "not-json" + ) == nil) + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedClear, + liveStatusJSON: liveClear + ) == nil) + for liveError in normalLiveErrors { + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedClear, + liveStatusJSON: liveError + ) == nil) + } + + let cachedStopped = status( + state: "idle", + reason: ConflictSafetyPolicy.stoppedReason + ) + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedStopped, + liveStatusJSON: liveClear + ) == ConflictSafetyPolicy.engineStopMarker) + + let cachedPermissionError = status( + reason: "permission_denied", + message: "fixture permission failure", + path: "/fixture/path" + ) + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: "sendonly", + cachedStatus: cachedPermissionError, + liveStatusJSON: liveClear + ) == nil) + + for folderType in ["sendreceive", "receiveonly", "receiveencrypted", "future-mode"] { + #expect(SyncthingManager.conflictMutationBlockCode( + folderType: folderType, + cachedStatus: cachedClear, + liveStatusJSON: liveClear + ) == ConflictSafetyPolicy.engineStopMarker) + } + } + + @Test("Sync-filter APIs are read-only while conflict safety is stopped or unknown (#150)") + @MainActor + func syncFilterMutationGatesStopAndUnknown() { + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ] { + let manager = makeManager() + manager._testSetFolderStatuses([ + "fixture-folder-a": status(state: "idle", reason: reason), + ]) + + let errors = [ + manager.setIgnorePatterns( + folderID: "fixture-folder-a", + patterns: ["fixture-pattern"] + ), + manager.togglePreset( + .workspace, + folderID: "fixture-folder-a", + enabled: true + ), + manager.addIgnorePatterns( + ["fixture-pattern"], + folderID: "fixture-folder-a" + ), + manager.removeIgnorePatterns( + ["fixture-pattern"], + folderID: "fixture-folder-a" + ), + manager.applyRecommendedFilters( + folderID: "fixture-folder-a", + enabledPresetIDs: [], + detectedPatterns: [], + enabledDetectedPatterns: [] + ), + ] + #expect(errors.allSatisfy { $0?.category == .conflictRetentionSafetyStop }) + } + } + + @Test("Startup, regular add, and pending accept have no delayed ignore or rescan mutation (#150, #167)") + func automaticFolderFollowUpsAreAbsent() throws { + let source = try productSource("VaultSync/Services/SyncthingManager.swift") + #expect(!source.contains("hasAppliedStartupIgnores")) + #expect(!source.contains("applyDefaultIgnoresIfNeeded")) + #expect(!source.contains("ensureDefaultIgnores")) + + let addFolder = try sourceSection( + source, + from: "func addFolder(id:", + to: "/// Remove a folder by ID." + ) + #expect(!addFolder.contains("Task.detached")) + + let pendingAccept = try sourceSection( + source, + from: "func acceptPendingFolder(folderID:", + to: "// MARK: - Device rename" + ) + #expect(!pendingAccept.contains("Task.detached")) + #expect(!pendingAccept.contains("SyncBridgeService.rescanFolder")) + } + + @Test("Blocked folders remain available as read-only conflict review destinations (#150)") + @MainActor + func blockedFolderIsExcludedFromConflictRouting() { + let manager = makeManager(folderIDs: ["blocked", "clear"]) + manager._testSetFolderStatuses([ + "blocked": status(state: "idle", reason: ConflictSafetyPolicy.stoppedReason), + "clear": status(state: "idle", reason: nil), + ]) + manager._testSetConflictFiles(["blocked": [conflict], "clear": [conflict]]) + + #expect(SyncIssuesView.conflictDestination( + preferredFolderID: "blocked", + conflictFiles: manager.conflictFiles, + allowFallback: true + ) == "blocked") + #expect(manager.unresolvedIssues.contains { + $0.kind == .conflicts && $0.folderID == "blocked" + }) + } + + @Test("Background settlement never treats safety or missing evidence as idle (#150)") + func backgroundSettlementIsFailClosed() { + let folders = """ + [{"id":"fixture-folder-a"},{"id":"fixture-folder-b"}] + """ + let stopped = """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorReason":"conflict_retention_safety_stop"} + """ + let collection = BackgroundSyncService.collectFolderStatusSnapshot( + foldersJSON: folders, + statusJSON: { $0 == "fixture-folder-a" ? stopped : "{}" } + ) + + #expect(collection?.allStatusesReadable == false) + #expect(collection?.settlements == ["fixture-folder-a": .errored]) + #expect(BackgroundSyncService.continuedProcessingFolderSnapshot( + foldersJSON: folders, + statusJSON: { $0 == "fixture-folder-a" ? stopped : "{}" } + ) == .unreadable) + #expect(BackgroundSyncService.collectFolderStatusSnapshot( + foldersJSON: "[{\"id\":\"duplicate\"},{\"id\":\"duplicate\"}]", + statusJSON: { _ in stopped } + ) == nil) + + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason, + ] { + #expect(BackgroundSyncService.folderSettlement( + state: "idle", + needFiles: 0, + needBytes: 0, + inProgressBytes: 0, + errorReason: reason + ) == .errored) + } + #expect(BackgroundSyncService.folderSettlement( + state: "idle", + needFiles: 0, + needBytes: 0, + inProgressBytes: 0, + errorReason: nil, + hasRawErrorDetail: true + ) == .errored) + } + + @Test("Automatic background rescan targets only SendOnly folders after full preflight (#150)") + func backgroundRescanIsFailClosed() { + let folders = """ + [{"id":"clear","type":"sendonly"},{"id":"blocked","type":"receiveonly"}] + """ + let sendOnlyFolders = """ + [{"id":"clear","type":"sendonly"},{"id":"also-clear","type":"sendonly"}] + """ + let clear = """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0} + """ + let stopped = """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorReason":"conflict_retention_safety_stop"} + """ + var rescanned: [String] = [] + var mixedStatusReads: [String] = [] + + let mixed = BackgroundSyncService.requestFolderRescans( + foldersJSON: folders, + statusJSON: { + mixedStatusReads.append($0) + return $0 == "clear" ? clear : stopped + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(mixed == .rescanned(1)) + #expect(mixedStatusReads == ["clear", "clear"]) + #expect(rescanned == ["clear"]) + #expect(BackgroundSyncService.syncResultForRescanFailure(mixed) == nil) + rescanned.removeAll() + + var receiveStatusReads = 0 + for folderType in ["sendreceive", "receiveonly", "receiveencrypted", "future-mode"] { + let receiveBlocked = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[{\"id\":\"receive\",\"type\":\"\(folderType)\"}]", + statusJSON: { _ in + receiveStatusReads += 1 + return clear + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(receiveBlocked == .blocked) + } + let missingTypeBlocked = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[{\"id\":\"missing-type\"}]", + statusJSON: { _ in + receiveStatusReads += 1 + return clear + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(missingTypeBlocked == .blocked) + #expect(receiveStatusReads == 0) + #expect(rescanned.isEmpty) + + for unsafeStatus in [ + stopped, + """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorReason":"folder_error_evidence_unavailable"} + """, + """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorReason":"folder_completion_evidence_unavailable"} + """, + ] { + let result = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[{\"id\":\"unknown\",\"type\":\"sendonly\"}]", + statusJSON: { _ in unsafeStatus }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(result == .blocked) + #expect(rescanned.isEmpty) + #expect(BackgroundSyncService.syncResultForRescanFailure(result) + != .alreadyIdle) + } + + for ordinarySendOnlyStatus in [ + """ + {"state":"error","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorReason":"generic_error"} + """, + """ + {"state":"idle","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0,"errorMessage":"redaction probe"} + """, + """ + {"state":"future-state","completionPct":100,"needFiles":0,"needBytes":0,"inProgressBytes":0} + """, + "{}", + ] { + let ordinaryResult = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[{\"id\":\"ordinary-send-only\",\"type\":\"sendonly\"}]", + statusJSON: { _ in ordinarySendOnlyStatus }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(ordinaryResult == .rescanned(1)) + #expect(rescanned == ["ordinary-send-only"]) + rescanned.removeAll() + } + + var statusReads = 0 + let changedAtGate = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[{\"id\":\"changed-at-gate\",\"type\":\"sendonly\"}]", + statusJSON: { _ in + statusReads += 1 + return statusReads == 1 ? clear : stopped + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(changedAtGate == .blocked) + #expect(statusReads == 2) + #expect(rescanned.isEmpty) + + let clearResult = BackgroundSyncService.requestFolderRescans( + foldersJSON: sendOnlyFolders, + statusJSON: { _ in clear }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(clearResult == .rescanned(2)) + #expect(rescanned == ["clear", "also-clear"]) + #expect(BackgroundSyncService.syncResultForRescanFailure(clearResult) == nil) + } + + @Test("Coalesced and terminal background success require fresh idle evidence (#150)") + func backgroundSuccessRequiresFinalIdleEvidence() { + #expect(BackgroundSyncService.coalescedSyncResult( + rescanResult: .rescanned(1) + ) == .failed) + #expect(BackgroundSyncService.coalescedSyncResult( + rescanResult: .blocked + ) == .settledWithFolderError) + + for proposed in [ + BackgroundSyncService.SyncResult.synced, + .alreadyIdle, + ] { + #expect(BackgroundSyncService.resultAfterFinalStatusValidation( + proposed: proposed, + finalSettlements: [.idle] + ) == proposed) + #expect(BackgroundSyncService.resultAfterFinalStatusValidation( + proposed: proposed, + finalSettlements: [.active] + ) == .failed) + #expect(BackgroundSyncService.resultAfterFinalStatusValidation( + proposed: proposed, + finalSettlements: [.idle, .errored] + ) == .settledWithFolderError) + #expect(BackgroundSyncService.resultAfterFinalStatusValidation( + proposed: proposed, + finalSettlements: nil + ) == .failed) + } + } + + @Test("Empty background folder batches report no vaults without reads or mutation (#150)") + func emptyBackgroundFolderBatchReportsNoVaults() { + var statusReads = 0 + var rescans = 0 + let result = BackgroundSyncService.requestFolderRescans( + foldersJSON: "[]", + statusJSON: { _ in + statusReads += 1 + return "{}" + }, + rescan: { _ in + rescans += 1 + return nil + } + ) + + #expect(result == .noFolders) + #expect(BackgroundSyncService.syncResultForRescanFailure(result) == .noFoldersConfigured) + #expect(BackgroundSyncService.coalescedSyncResult( + rescanResult: result + ) == .noFoldersConfigured) + #expect(statusReads == 0) + #expect(rescans == 0) + } + + @Test("Foreground receive rescans stop globally while send-only keeps its semantics (#150)") + func foregroundRescanIsFailClosed() { + let clear = liveStatusJSON() + let stopped = liveStatusJSON(reason: ConflictSafetyPolicy.stoppedReason) + var rescanned: [String] = [] + var statusReads = 0 + + let blocked = SyncthingManager.performForegroundRescans( + configuredFolders: [folder("send", type: "sendonly"), folder("receive")], + targetFolderIDs: ["send", "receive"], + statusJSON: { _ in + statusReads += 1 + return clear + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(blocked == .blocked(ConflictSafetyPolicy.engineStopMarker)) + #expect(rescanned.isEmpty) + #expect(statusReads == 0) + + var reads = 0 + let changedAtGate = SyncthingManager.performForegroundRescans( + configuredFolders: [folder("changed-at-gate", type: "sendonly")], + targetFolderIDs: ["changed-at-gate"], + statusJSON: { _ in + reads += 1 + return reads == 1 ? clear : stopped + }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(changedAtGate == .blocked(ConflictSafetyPolicy.engineStopMarker)) + #expect(reads == 2) + #expect(rescanned.isEmpty) + + for ordinarySendOnlyStatus in [ + liveStatusJSON(state: "error", reason: "generic_error"), + liveStatusJSON(state: "idle", reason: nil, message: "redaction probe"), + "{\"state\":\"idle\"}", + "not-json", + ] { + let ordinary = SyncthingManager.performForegroundRescans( + configuredFolders: [folder("ordinary-send-only", type: "sendonly")], + targetFolderIDs: ["ordinary-send-only"], + statusJSON: { _ in ordinarySendOnlyStatus }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(ordinary == .triggered) + #expect(rescanned == ["ordinary-send-only"]) + rescanned.removeAll() + } + + let duplicate = SyncthingManager.performForegroundRescans( + configuredFolders: [folder("duplicate", type: "sendonly")], + targetFolderIDs: ["duplicate", "duplicate"], + statusJSON: { _ in clear }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(duplicate == .blocked(ConflictSafetyPolicy.engineStopMarker)) + #expect(rescanned.isEmpty) + + let succeeded = SyncthingManager.performForegroundRescans( + configuredFolders: [ + folder("receive-sibling"), + folder("a", type: "sendonly"), + folder("b", type: "sendonly"), + ], + targetFolderIDs: ["a", "b"], + statusJSON: { _ in clear }, + rescan: { + rescanned.append($0) + return nil + } + ) + #expect(succeeded == .triggered) + #expect(rescanned == ["a", "b"]) + } + + @Test("Clear-shaped receive status cannot authorize mutation or success history (#150)") + @MainActor + func immutableRuntimeOverridesClearShapedReceiveStatus() { + let clearStatus = status(state: "idle", reason: nil) + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "sendreceive", + status: clearStatus + ) == .stopped) + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "receiveonly", + status: clearStatus + ) == .stopped) + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "receiveencrypted", + status: clearStatus + ) == .stopped) + #expect(SyncthingManager.effectiveConflictSafetyState( + folderType: "sendonly", + status: clearStatus + ) == .clear) + + let receiveManager = makeManager() + receiveManager._testSetFolderStatuses(["fixture-folder-a": clearStatus]) + #expect(receiveManager.setIgnorePatterns( + folderID: "fixture-folder-a", + patterns: ["fixture-pattern"] + )?.category == .conflictRetentionSafetyStop) + + #expect(!SyncthingManager.didTransitionToSuccessfulIdle( + previousState: "syncing", + status: clearStatus, + hasConnectedPeer: true, + safetyState: .stopped + )) + #expect(!SyncthingManager.shouldTreatIdleStateAsSuccess( + status: clearStatus, + stateChangedAt: Date(), + existingDate: nil, + hasConnectedPeer: true, + safetyState: .stopped + )) + } + + @Test("Failed final safety validation discards local progress proof (#150)") + func failedFinalSafetyValidationDiscardsProgressProof() { + #expect(BackgroundSyncService.validatedLocalDataProgressObserved( + proposed: true, + finalResult: .settledWithFolderError + ) == false) + #expect(BackgroundSyncService.validatedLocalDataProgressObserved( + proposed: true, + finalResult: .failed + ) == false) + #expect(BackgroundSyncService.validatedLocalDataProgressObserved( + proposed: true, + finalResult: .synced + )) + } + + @Test("Normal folder controls still require clear safety evidence (#150)") + func normalFolderMutationRequiresClearSafetyState() { + #expect(ConflictSafetyPolicy.allowsMutation(for: .clear)) + #expect(!ConflictSafetyPolicy.allowsMutation(for: .stopped)) + #expect(!ConflictSafetyPolicy.allowsMutation(for: .unknown)) + + #expect(ContentView.shouldOfferSyncFilterRecommendation( + safetyState: .clear, + isUnreachable: false, + hasShown: false, + isAlreadyPresented: false + )) + for state in [ConflictSafetyPolicy.State.stopped, .unknown] { + #expect(!ContentView.shouldOfferSyncFilterRecommendation( + safetyState: state, + isUnreachable: false, + hasShown: false, + isAlreadyPresented: false + )) + } + #expect(!ContentView.shouldOfferSyncFilterRecommendation( + safetyState: .clear, + isUnreachable: true, + hasShown: false, + isAlreadyPresented: false + )) + #expect(!ContentView.shouldOfferSyncFilterRecommendation( + safetyState: .clear, + isUnreachable: false, + hasShown: true, + isAlreadyPresented: false + )) + #expect(!ContentView.shouldOfferSyncFilterRecommendation( + safetyState: .clear, + isUnreachable: false, + hasShown: false, + isAlreadyPresented: true + )) + } + + @Test("Safety event reason and live status both beat success and redacted fields (#150)") + @MainActor + func safetyEventsAreFixedAndRedacted() throws { + let manager = makeManager() + let forbidden = [ + "redaction-probe-folder-id", + "Redaction Probe Vault", + "redaction-probe/path/item.md", + "REDACTION-PROBE-DEVICE", + "redaction-probe-reason", + ] + + let byReason = try #require(manager._testMakeSyncEventItem( + type: "StateChanged", + data: [ + "folder": forbidden[0], + "from": "syncing", + "to": "idle", + "reason": ConflictSafetyPolicy.stoppedReason, + "message": forbidden[4], + "path": forbidden[2], + "device": forbidden[3], + ], + folderNamesByID: [forbidden[0]: forbidden[1]], + deviceNamesByID: [forbidden[3]: "Redaction Probe Device"] + )) + let byLiveState = try #require(manager._testMakeSyncEventItem( + id: 2, + type: "ItemFinished", + data: [ + "folder": forbidden[0], + "item": forbidden[2], + "type": "file", + "action": "update", + ], + folderNamesByID: [forbidden[0]: forbidden[1]], + folderSafetyStates: [forbidden[0]: .unknown] + )) + + for event in [byReason, byLiveState] { + #expect(event.kind == .folderError) + #expect(event.folderID == nil) + #expect(event.deviceID == nil) + #expect(event.filePath == nil) + let rendered = "\(event.title)|\(event.detail)|\(event.folderID ?? "")|\(event.deviceID ?? "")|\(event.filePath ?? "")" + for secret in forbidden { + #expect(!rendered.contains(secret)) + } + } + } + + @Test("A folder error is a failed background fetch with no success copy (#150)") + @MainActor + func terminalFolderErrorIsFailure() { + #expect(AppDelegate.backgroundFetchResult(for: .settledWithFolderError) == .failed) + #expect(BackgroundSyncService.SyncResult.settledWithFolderError.shouldSurfaceIssue) + #expect(BackgroundSyncService.SyncResult.settledWithFolderError.issueTitle + != L10n.tr("Background Sync Completed")) + #expect(BackgroundSyncService.SyncResult.settledWithFolderError.remediation + != L10n.tr("No action needed.")) + } + + @Test("Conflict UI exposes inspection but no recovery entry point (#150)") + func conflictUIIsInspectionOnly() throws { + let detail = try productSource("VaultSync/Views/ConflictDiffView.swift") + #expect(detail.components(separatedBy: "SyncBridgeService.readFileContent").count == 3) + #expect(detail.contains("comparisonContent")) + for forbidden in [ + "ResolveAction", + "resolveConflict(", + "keepBothConflict(", + "skipFileAndCleanupConflicts(", + "Always skip on this iPhone", + "Conflict Resolved", + "Nothing is discarded", + "Both versions were kept", + "was overwritten", + "was discarded", + "was renamed", + "guard allowsResolution", + ] { + #expect(!detail.contains(forbidden)) + } + + let list = try productSource("VaultSync/Views/ConflictListView.swift") + #expect(list.contains("NavigationLink")) + #expect(list.contains("Conflict Recovery Unavailable")) + #expect(!list.contains("allowsResolution")) + #expect(!list.contains("All conflicts resolved")) + } + + @Test("Pending shares expose inspection only and no shipping mutation wiring (#150)") + @MainActor + func pendingShareSurfacesAreReadOnlyIssue150() throws { + let pending = try productSource("VaultSync/Views/PendingSharesView.swift") + #expect(pending.contains("Pending shares are read-only in this version.")) + #expect(pending.contains("Read Only")) + for forbidden in [ + "Button(", + "onAccept", + "onRetry", + "onIgnore", + "onRestoreIgnored", + "onChooseTarget", + "onReconnectObsidian", + "Accept Share", + "Review and Accept", + "Choose Vault…", + "Restore Share", + "Applying…", + "L10n.tr(\"Ready\")", + ] { + #expect(!pending.contains(forbidden)) + } + + let content = try productSource("VaultSync/Views/ContentView.swift") + for forbidden in [ + "var shareAccept:", + "shareTargetPickerFolder", + "Merge and Sync", + "ShareTargetPickerView(", + "acceptFirstPendingShareFromIssues", + "runAutomaticPass()", + "confirmMergeAccept(", + "acceptManually(", + ] { + #expect(!content.contains(forbidden)) + } + + let onboarding = try productSource("VaultSync/Views/OnboardingView.swift") + for forbidden in [ + "var shareAccept:", + "runAutomaticPass()", + "clearRecordedFailures()", + "accepts it automatically", + "accepting…", + "needs your attention", + ] { + #expect(!onboarding.contains(forbidden)) + } + + let issues = try productSource("VaultSync/Views/SyncIssuesView.swift") + #expect(!issues.contains("onAcceptFirstPendingShare")) + let pendingIssueCase = try sourceSection( + issues, + from: "case .pendingShares:\n // Retained enum case", + to: "case .conflicts:" + ) + #expect(!pendingIssueCase.contains("Button(")) + #expect(!pendingIssueCase.contains("Accept")) + + let app = try productSource("VaultSync/App/VaultSyncApp.swift") + #expect(!app.contains("ShareAcceptCoordinator")) + + let coordinator = try productSource("VaultSync/ViewModels/ShareAcceptCoordinator.swift") + #expect(!coordinator.contains("static func live(")) + #expect(!coordinator.contains("vaultManager.acceptPendingShare")) + + let reconnect = try productSource("VaultSync/ViewModels/ObsidianReconnectFlow.swift") + #expect(!reconnect.contains("retryPendingShares")) + + let bridge = try productSource("VaultSync/Services/SyncBridgeService.swift") + let acceptABI = try sourceSection( + bridge, + from: "/// Stable wrapper for the retained gomobile ABI.", + to: "// MARK: - Phase 6: Device rename" + ) + #expect(acceptABI.contains("unavailable in 2.0.2")) + + let manager = makeManager(folderIDs: []) + manager._testSetPendingFolders([ + .init(id: "fixture-offer", label: "Fixture Offer", offeredBy: []), + ]) + #expect(!manager.unresolvedIssues.contains { $0.kind == .pendingShares }) + } + + @Test("Every conflict recovery facade stops before bridge filter cleanup and rescan work (#150)") + func recoveryFacadesAreGlobalStubs() throws { + let source = try productSource("VaultSync/Services/SyncthingManager.swift") + let directRecovery = try sourceSection( + source, + from: "func resolveConflict(folderID:", + to: "// MARK: - Pending folder shares" + ) + #expect(directRecovery.contains("vaultsync-conflict-recovery-unavailable")) + #expect(!directRecovery.contains("SyncBridgeService.resolveConflict")) + #expect(!directRecovery.contains("SyncBridgeService.keepBothConflict")) + #expect(!directRecovery.contains("refreshConflicts")) + + let alwaysSkip = try sourceSection( + source, + from: "func skipFileAndCleanupConflicts(folderID:", + to: "// MARK: - Test hooks" + ) + #expect(alwaysSkip.contains("vaultsync-conflict-recovery-unavailable")) + for forbidden in [ + "conflictMutationBlockCode", + "readIgnorePatternsOrNil", + "setIgnorePatterns", + "removeConflictFilesForOriginal", + "rescanFolder", + "refreshConflicts", + ] { + #expect(!alwaysSkip.contains(forbidden)) + } + } + + @Test("Automatic path, accept, diagnostics, and new safety-pause writers are unreachable (#150)") + func automaticConfigurationAndFileWritersAreUnavailable() throws { + let reconciler = try productSource("VaultSync/Services/FolderPathReconciler.swift") + #expect(reconciler.contains("liveReconcileCandidates")) + + let manager = try productSource("VaultSync/Services/SyncthingManager.swift") + let add = try sourceSection( + manager, + from: "func addFolder(id:", + to: "/// Remove a folder by ID." + ) + #expect(add.contains(ConflictSafetyPolicy.engineStopMarker)) + #expect(!add.contains("SyncBridgeService.addFolder")) + + let accept = try sourceSection( + manager, + from: "func acceptPendingFolder(folderID:", + to: "// MARK: - Device rename" + ) + #expect(accept.contains(ConflictSafetyPolicy.engineStopMarker)) + #expect(!accept.contains("SyncBridgeService.acceptPendingFolder")) + + let diagnostics = try productSource("VaultSync/Views/ControlledDiagnosticsView.swift") + let namespaceAction = try sourceSection( + diagnostics, + from: "case .namespaceActive:", + to: "default:" + ) + #expect(!namespaceAction.contains("Start Foreground Upload and Download Check")) + #expect(!namespaceAction.contains("beginForegroundUpload")) + + let app = try productSource("VaultSync/App/VaultSyncApp.swift") + let content = try productSource("VaultSync/Views/ContentView.swift") + #expect(!app.contains("setFolderPaused")) + #expect(!content.contains("setFolderPaused")) + + let collisionGuard = try productSource("VaultSync/Services/PathCollisionGuard.swift") + #expect(collisionGuard.contains("setPaused")) + } + + @Test("Conflict inspection distinguishes content, empty files, and unavailable reads (#150)") + func conflictFileInspectionPayloadIsUnambiguousIssue150() { + #expect(SyncBridgeService.decodeFileInspectionResult( + #"{"content":"error:legitimate note text"}"# + ) == .content("error:legitimate note text")) + #expect(SyncBridgeService.decodeFileInspectionResult( + #"{"content":""}"# + ) == .content("")) + #expect(SyncBridgeService.decodeFileInspectionResult( + #"{"error":"vaultsync-conflict-inspection-unavailable"}"# + ) == .unavailable) + #expect(SyncBridgeService.decodeFileInspectionResult( + "error:redaction-probe-note.md" + ) == .unavailable) + } + + @Test("Unavailable conflict inspection preserves prior review copies without claiming empty (#150)") + func conflictInspectionCacheIsFailClosedIssue150() { + let previous = ["a": [conflict], "removed": [conflict]] + let snapshot = SyncthingManager.mergeConflictInspection( + previous: previous, + activeFolderIDs: ["a", "b", "c"], + rawByFolder: [ + "a": "vaultsync-conflict-inspection-unavailable", + "b": "[]", + "c": String(data: try! JSONEncoder().encode([conflict]), encoding: .utf8)!, + ] + ) + + #expect(snapshot.conflicts["a"]?.map(\.conflictPath) == [conflict.conflictPath]) + #expect(snapshot.conflicts["b"] == nil) + #expect(snapshot.conflicts["c"]?.map(\.conflictPath) == [conflict.conflictPath]) + #expect(snapshot.conflicts["removed"] == nil) + #expect(snapshot.unavailableFolderIDs == ["a"]) + } + + @Test("Default foreground rescans select only SendOnly folders as one batch (#150)") + func defaultForegroundRescanTargetsAreSendOnlyIssue150() throws { + let folders = [ + folder("receive-first", type: "sendreceive"), + folder("send-b", type: "sendonly"), + folder("send-a", type: "sendonly"), + folder("receive-last", type: "receiveonly"), + ] + #expect(SyncthingManager.defaultForegroundRescanTargetFolderIDs(folders) == ["send-a", "send-b"]) + + let content = try productSource("VaultSync/Views/ContentView.swift") + let issueRescans = try sourceSection( + content, + from: "private func rescanFailedVaults()", + to: "// MARK: - Unreachable Vaults" + ) + #expect(!issueRescans.contains("syncthingManager.rescanFolder(id:")) + #expect(issueRescans.contains("triggerForegroundSync(folderIDs:")) + } + + @Test("Protected marker loss keeps specific manual guidance while mutations stay stopped (#150, #65)") + @MainActor + func protectedMarkerLossKeepsIntegrityGuidanceIssue150() { + let manager = makeManager() + manager._testSetFolderStatuses([ + "fixture-folder-a": status( + state: "error", + reason: "unknown_error", + message: "folder marker missing" + ), + ]) + + let diagnostic = manager.folderUserError(folderID: "fixture-folder-a") + #expect(diagnostic?.category == .folderMarkerMissing) + #expect(diagnostic?.remediation.localizedCaseInsensitiveContains("rescan") == false) + #expect(manager.rescanFolder(id: "fixture-folder-a") == ConflictSafetyPolicy.engineStopMarker) + #expect(manager.unresolvedIssues.contains { $0.kind == .folderErrors }) + #expect(!manager.hasRescanableFolderErrors) + } + + @Test("Conflict views use neutral provenance and never expose retry or false-empty copy (#150)") + func conflictInspectionUIIsNeutralIssue150() throws { + let detail = try productSource("VaultSync/Views/ConflictDiffView.swift") + #expect(detail.contains("Current File")) + #expect(detail.contains("Conflict Copy")) + #expect(detail.contains("This copy is unavailable for inspection.")) + #expect(detail.contains("(empty)")) + for forbidden in ["This Device", "Other Device", "SyncUserError.from", "(empty or unreadable)"] { + #expect(!detail.contains(forbidden)) + } + + let list = try productSource("VaultSync/Views/ConflictListView.swift") + #expect(list.contains("conflictInspectionUnavailableFolderIDs")) + #expect(list.contains("Conflict inspection is unavailable.")) + #expect(!list.contains("Label(conflict.deviceShortID")) + } + + @Test("Production engine databases stay in the main app's private home (#150)") + func appPrivateDatabaseOwnershipBoundaryIssue150() throws { + let manager = try productSource("VaultSync/Services/SyncthingManager.swift") + let foregroundHome = try sourceSection( + manager, + from: "private static func configDirectory()", + to: "private func markPendingShareSeen()" + ) + #expect(foregroundHome.contains(".documentDirectory")) + #expect(foregroundHome.contains("appendingPathComponent(\"syncthing\"")) + + let background = try productSource("VaultSync/Services/BackgroundSyncService.swift") + let backgroundHome = try sourceSection( + background, + from: "private static func syncthingConfigDir()", + to: "enum FolderRescanResult" + ) + #expect(backgroundHome.contains(".documentDirectory")) + #expect(backgroundHome.contains("appendingPathComponent(\"syncthing\"")) + + let project = try productSource("project.yml") + #expect(!project.contains("UIFileSharingEnabled")) + #expect(!project.contains("LSSupportsOpeningDocumentsInPlace")) + #expect(project.components(separatedBy: "framework: ../go/build/SyncBridge.xcframework").count == 2) + + let targetsStart = try #require(project.range(of: "targets:\n")?.upperBound) + let targetNames = Set(project[targetsStart...].split(separator: "\n").compactMap { line -> String? in + guard line.hasPrefix(" "), !line.hasPrefix(" "), line.hasSuffix(":") else { + return nil + } + return String(line.dropFirst(2).dropLast()) + }) + #expect(targetNames == Set(["VaultSync", "VaultSyncWidget", "VaultSyncTests"])) + + let widgetTarget = try sourceSection( + project, + from: " VaultSyncWidget:", + to: " VaultSyncTests:" + ) + #expect(!widgetTarget.contains("SyncBridge")) + let widget = try productSource("VaultSyncWidget/VaultSyncWidget.swift") + #expect(!widget.contains("SyncBridgeService")) + #expect(!widget.contains("BridgeStartSyncthing")) + #expect(widget.contains("UserDefaults(suiteName:")) + + let app = try productSource("VaultSync/App/VaultSyncApp.swift") + #expect(app.components(separatedBy: "SyncthingManager()").count == 2) + } + + @Test("Controlled diagnostics stops before every file, scan, event, or success effect (#150)") + @MainActor + func controlledDiagnosticsStopsBeforeEveryEffect() { + let counter = Issue150EffectCounter() + let controller = DiagnosticsPairingController( + uploadFileWriter: { _, _, _ in counter.record("writer") } + ) + + controller.beginForegroundUpload( + recordID: "fixture-record", + receiveSafetyState: { .stopped }, + preflight: { _, _, _ in + counter.record("preflight") + fatalError("preflight must remain unreachable") + }, + rescan: { + counter.record("rescan") + return true + }, + events: { _ in + counter.record("events") + return nil + } + ) + + #expect(controller.uploadStatuses["fixture-record"]?.phase == .unavailable) + #expect(controller.lastError == .unavailable) + for effect in ["preflight", "writer", "rescan", "events"] { + #expect(counter.value(effect) == 0) + } + } + + private func productSource( + _ relativePath: String, + filePath: StaticString = #filePath + ) throws -> String { + let iosDirectory = URL(fileURLWithPath: "\(filePath)") + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: iosDirectory.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } + + private func sourceSection( + _ source: String, + from startMarker: String, + to endMarker: String + ) throws -> String { + let start = try #require(source.range(of: startMarker)?.lowerBound) + let end = try #require(source.range(of: endMarker, range: start.. SyncthingManager.FolderStatusInfo { SyncthingManager.FolderStatusInfo(payload: .init( @@ -23,13 +24,35 @@ struct FirstSyncDetectionTests { needBytes: 0, needFiles: needFiles, inProgressBytes: 0, - errorReason: nil, + errorReason: errorReason, errorMessage: errorMessage, errorPath: nil, errorChanged: nil )) } + @Test("Safety stop and unavailable evidence never become a successful idle transition (#150)") + func conflictSafetyCannotStampSyncHistory() { + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ConflictSafetyPolicy.folderCompletionEvidenceUnavailableReason, + ] { + let status = makeStatus(state: "idle", errorReason: reason) + #expect(!SyncthingManager.didTransitionToSuccessfulIdle( + previousState: "syncing", + status: status, + hasConnectedPeer: true + )) + #expect(!SyncthingManager.shouldTreatIdleStateAsSuccess( + status: status, + stateChangedAt: Date(), + existingDate: nil, + hasConnectedPeer: true + )) + } + } + @Test("Reported repro: empty accepted share, peer offline — scan-to-idle is not a sync") func emptyScanWithoutPeerIsNotASync() { let status = makeStatus(state: "idle") diff --git a/ios/VaultSyncTests/FolderPathReconcilerTests.swift b/ios/VaultSyncTests/FolderPathReconcilerTests.swift index 68837b6..8ea226d 100644 --- a/ios/VaultSyncTests/FolderPathReconcilerTests.swift +++ b/ios/VaultSyncTests/FolderPathReconcilerTests.swift @@ -4,6 +4,20 @@ import Testing @Suite("FolderPathReconciler — launch-time path rebasing") struct FolderPathReconcilerTests { + @Test("Live reconcile excludes every receive type but preserves send-only (#150)") + func liveReconcileCandidatesRespectImmutableReceivePolicy() { + let candidates = FolderPathReconciler.liveReconcileCandidates([ + (id: "sr", path: "/sr", type: "sendreceive"), + (id: "ro", path: "/ro", type: "receiveonly"), + (id: "re", path: "/re", type: "receiveencrypted"), + (id: "so", path: "/so", type: "sendonly"), + (id: "future", path: "/future", type: "future-mode"), + ]) + + #expect(candidates.map(\.id) == ["so"]) + #expect(candidates.map(\.path) == ["/so"]) + } + /// In-memory backing for an injected `Environment`, recording every /// `setPath` call and exposing the final relative-path map. diff --git a/ios/VaultSyncTests/Issue95GuidanceTests.swift b/ios/VaultSyncTests/Issue95GuidanceTests.swift index f1a3503..bf564e0 100644 --- a/ios/VaultSyncTests/Issue95GuidanceTests.swift +++ b/ios/VaultSyncTests/Issue95GuidanceTests.swift @@ -55,6 +55,7 @@ struct CoordinatorRefusalModalGateTests { let env = ShareAcceptCoordinator.Environment( settled: { true }, vaultAccessible: { true }, + receiveSafetyState: { .clear }, pendingFolders: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, autoAcceptEligible: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, accept: { _, _ in .refused(message: "no safe location") }, @@ -77,6 +78,7 @@ struct CoordinatorRefusalModalGateTests { let env = ShareAcceptCoordinator.Environment( settled: { true }, vaultAccessible: { true }, + receiveSafetyState: { .clear }, pendingFolders: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, autoAcceptEligible: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, accept: { _, _ in .refused(message: "no safe location") }, @@ -102,6 +104,7 @@ struct CoordinatorRefusalModalGateTests { let env = ShareAcceptCoordinator.Environment( settled: { true }, vaultAccessible: { true }, + receiveSafetyState: { .clear }, pendingFolders: { [f1, f2] }, autoAcceptEligible: { [f1, f2] }, accept: { _, _ in .refused(message: "no safe location") }, @@ -127,6 +130,7 @@ struct CoordinatorRefusalModalGateTests { let env = ShareAcceptCoordinator.Environment( settled: { true }, vaultAccessible: { true }, + receiveSafetyState: { .clear }, pendingFolders: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, autoAcceptEligible: { [SyncthingManager.PendingFolderInfo(id: "f1", label: "F1", offeredBy: [])] }, accept: { _, _ in .accepted }, @@ -147,16 +151,16 @@ struct SyncHeaderOpensChecklistTests { func gating() { #expect(SyncHeaderModel.opensChecklist(titleKey: "Finish Setup")) #expect(SyncHeaderModel.opensChecklist(titleKey: "Action Needed")) - for key in ["Error", "Starting…", "Sync Issue", "Syncing…", "All Synced", "Ready", "No Vaults Yet"] { + for key in ["Error", "Starting…", "Sync Issue", "Syncing…", "All Synced", "No Vaults Syncing", "No Vaults Yet"] { #expect(!SyncHeaderModel.opensChecklist(titleKey: key), "\(key) must not open the checklist") } } } @MainActor -@Suite("Checklist ignored-offer remediation (#95)", .serialized) +@Suite("Checklist pending offers stay inspection-only (#95, #150)", .serialized) struct ChecklistIgnoredOfferTests { - @Test("An ignored-only offer points at Restore Share, not at re-sharing from the desktop") + @Test("An ignored-only offer exposes details without a recovery action (#150)") func ignoredOnlyOfferBranch() { TestSupport.resetSyncthingState() TestSupport.resetRelayState() @@ -178,12 +182,11 @@ struct ChecklistIgnoredOfferTests { let item = viewModel.items.first { $0.requirement == .firstShareDetectedOrAccepted } #expect(item?.isComplete == false) - #expect(item?.remediation.contains("Restore Share") == true) - // The dead-end advice must be gone in this state: - #expect(item?.remediation.contains("share your Obsidian vault again") != true) + #expect(item?.description == L10n.tr("An ignored vault offer remains stored on this iPhone.")) + #expect(item?.remediation == L10n.tr("Open Pending Shares to inspect its details. No action is available in this version.")) } - @Test("An actionable offer still wins over an ignored one") + @Test("An actionable offer wins but remains inspection-only (#150)") func actionableWinsOverIgnored() { TestSupport.resetSyncthingState() TestSupport.resetRelayState() @@ -202,6 +205,7 @@ struct ChecklistIgnoredOfferTests { defer { TestSupport.resetSyncthingState() } let item = viewModel.items.first { $0.requirement == .firstShareDetectedOrAccepted } - #expect(item?.description.contains("waiting") == true) // "A vault offer is waiting to be accepted." + #expect(item?.description == L10n.tr("A vault offer is available for inspection.")) + #expect(item?.remediation == L10n.tr("Open Pending Shares to inspect the offer details. This version cannot accept it.")) } } diff --git a/ios/VaultSyncTests/ObsidianReconnectFlowTests.swift b/ios/VaultSyncTests/ObsidianReconnectFlowTests.swift index 1bb2474..73ee20c 100644 --- a/ios/VaultSyncTests/ObsidianReconnectFlowTests.swift +++ b/ios/VaultSyncTests/ObsidianReconnectFlowTests.swift @@ -3,10 +3,10 @@ import Testing @testable import VaultSync @MainActor -@Suite("Auto-accept fires after Obsidian reconnect (#53)") +@Suite("Obsidian reconnect never mutates pending shares (#150)") struct ObsidianReconnectFlowTests { - @Test("Successful grant runs the full sequence in order: immediate feedback, reconcile, then the accept pass") + @Test("Successful grant publishes feedback then reconciles without a pending-share effect (#150)") func successRunsFullSequenceInOrder() async { var events: [String] = [] @@ -20,55 +20,54 @@ struct ObsidianReconnectFlowTests { // and break the order assertion below. await Task.yield() events.append("reconcile-end") - }, - retryPendingShares: { events.append("retry") } + } ) #expect(error == nil) - #expect(events == ["grant", "feedback", "reconcile-start", "reconcile-end", "retry"]) + #expect(events == ["grant", "feedback", "reconcile-start", "reconcile-end"]) } - @Test("Failed grant short-circuits: no feedback, no reconcile, no accept pass") + @Test("Failed grant short-circuits before feedback and reconcile (#150)") func failedGrantShortCircuits() async { var events: [String] = [] let error = await ObsidianReconnectFlow.run( grantAccess: { events.append("grant"); return "no access" }, onGrantSucceeded: { events.append("feedback") }, - reconcile: { events.append("reconcile") }, - retryPendingShares: { events.append("retry") } + reconcile: { events.append("reconcile") } ) #expect(error == "no access") #expect(events == ["grant"]) } - @Test("A reconcile that does not return fires no accept pass — no timeout fallback; the standing pendingFolders change trigger covers that case — and a late reconcile still completes the sequence") - func hangingReconcileFiresNoRetryUntilItReturns() async { - var retried = false + @Test("A reconnect waits for reconciliation and has no timeout side effect (#150)") + func hangingReconcileHasNoTimeoutSideEffect() async { + var finished = false var releaseReconcile: CheckedContinuation? let flow = Task { - await ObsidianReconnectFlow.run( + let result = await ObsidianReconnectFlow.run( grantAccess: { nil }, onGrantSucceeded: { }, reconcile: { await withCheckedContinuation { releaseReconcile = $0 } - }, - retryPendingShares: { retried = true } + } ) + finished = true + return result } // Wait until the flow is suspended inside the reconcile, then give it - // ample opportunity to (wrongly) fire the retry while still pending. + // ample opportunity to (wrongly) finish while still pending. while releaseReconcile == nil { await Task.yield() } for _ in 0..<50 { await Task.yield() } - #expect(!retried) + #expect(!finished) // A reconcile that eventually returns still completes the sequence. releaseReconcile?.resume() let error = await flow.value #expect(error == nil) - #expect(retried) + #expect(finished) } } diff --git a/ios/VaultSyncTests/SecurityScopedLeaseTakeoverTests.swift b/ios/VaultSyncTests/SecurityScopedLeaseTakeoverTests.swift index 717372a..8359465 100644 --- a/ios/VaultSyncTests/SecurityScopedLeaseTakeoverTests.swift +++ b/ios/VaultSyncTests/SecurityScopedLeaseTakeoverTests.swift @@ -781,8 +781,7 @@ struct SecurityScopedLeaseTakeoverTests { return manager.grantAccess(url: candidateURL) }, onGrantSucceeded: { flowEvents.append("success") }, - reconcile: { flowEvents.append("reconcile") }, - retryPendingShares: { flowEvents.append("retry") } + reconcile: { flowEvents.append("reconcile") } ) #expect(error != nil) diff --git a/ios/VaultSyncTests/SetupChecklistViewModelTests.swift b/ios/VaultSyncTests/SetupChecklistViewModelTests.swift index e7b05de..4d0aacb 100644 --- a/ios/VaultSyncTests/SetupChecklistViewModelTests.swift +++ b/ios/VaultSyncTests/SetupChecklistViewModelTests.swift @@ -56,13 +56,16 @@ struct SetupChecklistViewModelTests { let addDeviceError = syncthingManager.addDevice(id: TestSupport.samplePeerDeviceID, name: "Desktop") #expect(addDeviceError == nil) - let folderID = "checklist-share-\(UUID().uuidString.prefix(8))" - let folderPath = FileManager.default.temporaryDirectory - .appendingPathComponent("vaultsync-tests", isDirectory: true) - .appendingPathComponent(String(folderID), isDirectory: true) - .path - let addFolderError = syncthingManager.addFolder(id: String(folderID), label: "Checklist Share", path: folderPath) - #expect(addFolderError == nil) + syncthingManager._testSetFolders([ + .init( + id: "checklist-send-only", + label: "Checklist Share", + path: "/synthetic/checklist", + type: "sendonly", + paused: false, + deviceIDs: [TestSupport.samplePeerDeviceID] + ), + ]) let stateByRequirement = Dictionary( uniqueKeysWithValues: viewModel.items.map { ($0.requirement, $0.isComplete) } @@ -95,16 +98,49 @@ struct SetupChecklistViewModelTests { let vaultSyncingItem = viewModel.items.first { $0.requirement == .firstShareDetectedOrAccepted } #expect(vaultSyncingItem != nil) - #expect(vaultSyncingItem?.title == L10n.tr("Vault syncing")) + #expect(vaultSyncingItem?.title == L10n.tr("Vault setup")) #expect(vaultSyncingItem?.isComplete == false) - #expect(vaultSyncingItem?.description == L10n.tr("A vault offer was seen earlier, but no vault is syncing right now.")) + #expect(vaultSyncingItem?.description == L10n.tr("A vault offer was seen earlier, but no vault is configured right now.")) #expect( vaultSyncingItem?.remediation - == L10n.tr("If syncing has not started, share your Obsidian vault again from Syncthing on your computer.") + == L10n.tr("New share acceptance is unavailable in this version.") ) #expect(viewModel.completedRequiredCount == 0) } + @Test("Pending offers stay inspection-only throughout the checklist (#150)") + func pendingOffersStayInspectionOnlyIssue150() { + TestSupport.resetSyncthingState() + TestSupport.resetRelayState() + defer { TestSupport.resetSyncthingState() } + + let syncthingManager = SyncthingManager() + let viewModel = SetupChecklistViewModel( + syncthingManager: syncthingManager, + vaultManager: VaultManager(), + subscriptionManager: SubscriptionManager() + ) + syncthingManager._testSetPendingFolders([ + .init(id: "issue-150-offer", label: "Synthetic Offer", offeredBy: []), + ]) + + var item = viewModel.items.first { $0.requirement == .firstShareDetectedOrAccepted } + #expect(item?.isComplete == false) + #expect(item?.description == L10n.tr("A vault offer is available for inspection.")) + #expect(item?.remediation == L10n.tr("Open Pending Shares to inspect the offer details. This version cannot accept it.")) + + syncthingManager.ignorePendingFolder(id: "issue-150-offer") + item = viewModel.items.first { $0.requirement == .firstShareDetectedOrAccepted } + #expect(item?.isComplete == false) + #expect(item?.description == L10n.tr("An ignored vault offer remains stored on this iPhone.")) + #expect(item?.remediation == L10n.tr("Open Pending Shares to inspect its details. No action is available in this version.")) + + let rendered = "\(item?.description ?? "")|\(item?.remediation ?? "")".lowercased() + for forbidden in ["accept", "retry", "restore", "ready", "automatically"] { + #expect(!rendered.contains(forbidden)) + } + } + @Test("Relay checklist state covers all three branches") func relayChecklistStateTransitions() { // Not subscribed → notSubscribed regardless of delivery signal. diff --git a/ios/VaultSyncTests/ShareAcceptCoordinatorTests.swift b/ios/VaultSyncTests/ShareAcceptCoordinatorTests.swift index dd25bec..97c9dc6 100644 --- a/ios/VaultSyncTests/ShareAcceptCoordinatorTests.swift +++ b/ios/VaultSyncTests/ShareAcceptCoordinatorTests.swift @@ -21,6 +21,7 @@ struct ShareAcceptCoordinatorTests { private static func env( settled: @escaping @MainActor () -> Bool = { true }, accessible: @escaping @MainActor () -> Bool = { true }, + receiveSafetyState: @escaping @MainActor () -> ConflictSafetyPolicy.State = { .clear }, pending: [SyncthingManager.PendingFolderInfo], eligible: [SyncthingManager.PendingFolderInfo]? = nil, recorder: Recorder, @@ -30,6 +31,7 @@ struct ShareAcceptCoordinatorTests { ShareAcceptCoordinator.Environment( settled: settled, vaultAccessible: accessible, + receiveSafetyState: receiveSafetyState, pendingFolders: { pending }, autoAcceptEligible: { eligible ?? pending }, accept: { folder, mergeConfirmed in @@ -42,6 +44,57 @@ struct ShareAcceptCoordinatorTests { ) } + @Test("Immutable receive safety blocks automatic, manual, retry, and target accepts (#150)") + func immutableReceiveSafetyBlocksEveryAcceptPath() { + let recorder = Recorder() + var manualTargetReached = false + let offer = Self.offer("f1") + let c = ShareAcceptCoordinator(environment: Self.env( + receiveSafetyState: { .stopped }, + pending: [offer], + recorder: recorder, + acceptIntoTarget: { _, _ in + manualTargetReached = true + return nil + } + )) + + c.runAutomaticPass() + c.accept(offer, source: .manual) + c.retry(offer) + let targetError = c.acceptManually(folder: offer, intoTargetNamed: "Target") + + #expect(recorder.accepts.isEmpty) + #expect(!manualTargetReached) + #expect(c.pendingMergeConfirmation == nil) + #expect(c.pendingShareFailures[offer.id]?.category == .conflictRetentionSafetyStop) + #expect(targetError?.localizedCaseInsensitiveContains("try again") == false) + } + + @Test("An omitted receive-safety dependency defaults to unknown and accepts nothing (#150)") + func omittedReceiveSafetyDependencyIsFailClosedIssue150() { + let recorder = Recorder() + let offer = Self.offer("f1") + let c = ShareAcceptCoordinator(environment: .init( + settled: { true }, + vaultAccessible: { true }, + pendingFolders: { [offer] }, + autoAcceptEligible: { [offer] }, + accept: { folder, mergeConfirmed in + recorder.accepts.append((folder.id, mergeConfirmed)) + return .accepted + }, + acceptIntoTarget: { _, _ in nil }, + unignorePendingFolder: { _ in }, + ignorePendingFolder: { _ in } + )) + + c.runAutomaticPass() + + #expect(recorder.accepts.isEmpty) + #expect(c.pendingShareFailures[offer.id]?.category == .conflictRetentionSafetyStop) + } + @Test("Unsettled paths hold the automatic pass without recording failures (decision 008 — nothing may block the re-fire)") func unsettledPathsHoldAutomaticPass() { let recorder = Recorder() diff --git a/ios/VaultSyncTests/SyncHeaderModelTests.swift b/ios/VaultSyncTests/SyncHeaderModelTests.swift index 0b5a737..f1e9cb7 100644 --- a/ios/VaultSyncTests/SyncHeaderModelTests.swift +++ b/ios/VaultSyncTests/SyncHeaderModelTests.swift @@ -89,13 +89,13 @@ struct SyncHeaderModelTests { #expect(SyncHeaderModel.derive(healthy()) == .init(status: .synced, titleKey: "All Synced")) } - // "Ready" only when genuinely armed: vault accessible and a vault exists, - // so the auto-accept pass could act the moment a share arrives. - @Test("No sync folders but armed reads Ready") - func armedReadsReady() { + // New share acceptance is unavailable in 2.0.2, so a detected local vault + // cannot make a folder-less installation ready or synced (#150). + @Test("No sync folders stay neutral while share acceptance is unavailable (#150)") + func folderlessStateIsReadOnlyIssue150() { var inputs = healthy() inputs.hasSyncFolders = false - #expect(SyncHeaderModel.derive(inputs) == .init(status: .synced, titleKey: "Ready")) + #expect(SyncHeaderModel.derive(inputs) == .init(status: .starting, titleKey: "No Vaults Syncing")) } // The reported contradiction: green "Ready" next to "No vaults found / @@ -135,7 +135,7 @@ struct SyncHeaderModelTests { func onlyKnownTitleKeys() { let knownKeys: Set = [ "Error", "Starting…", "Sync Issue", "Syncing…", "Action Needed", - "Finish Setup", "All Synced", "Ready", "No Vaults Yet", + "Finish Setup", "All Synced", "No Vaults Syncing", "No Vaults Yet", ] var inputs = SyncHeaderModel.Inputs( hasEngineError: false, diff --git a/ios/VaultSyncTests/SyncUserErrorTests.swift b/ios/VaultSyncTests/SyncUserErrorTests.swift index 2e7ee32..64429cd 100644 --- a/ios/VaultSyncTests/SyncUserErrorTests.swift +++ b/ios/VaultSyncTests/SyncUserErrorTests.swift @@ -39,6 +39,28 @@ struct SyncUserErrorTests { ) #expect(missing.category == .config) #expect(missing.title == L10n.tr("Folder Not Configured")) + + let retentionStop = SyncUserError.fromFolderStatus( + reason: ConflictSafetyPolicy.stoppedReason, + message: "marker missing at /redaction-probe/vault-note", + path: "/redaction-probe/vault-note" + ) + #expect(retentionStop.category == .conflictRetentionSafetyStop) + #expect(retentionStop.title == L10n.tr("Conflict Safety Review Required")) + #expect(retentionStop.technicalDetails == nil) + #expect(SyncUserError.troubleshootingURL(for: retentionStop) == nil) + let rendered = "\(retentionStop.id)|\(retentionStop.userVisibleDescription)" + #expect(!rendered.contains("redaction-probe")) + + let unavailable = SyncUserError.fromFolderStatus( + reason: ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + message: "sensitive reason /redaction-probe/path", + path: "/redaction-probe/path" + ) + #expect(unavailable.category == .conflictRetentionSafetyStop) + #expect(unavailable.title == L10n.tr("Conflict Safety Status Unavailable")) + #expect(unavailable.technicalDetails == nil) + #expect(!"\(unavailable.id)|\(unavailable.userVisibleDescription)".contains("/redaction-probe/path")) } @Test("Maps relay provisioning failures for rate limiting and unknown causes") @@ -60,6 +82,52 @@ struct SyncUserErrorTests { #expect(error.title == "Bridge Failure") #expect(error.remediation == L10n.tr("Retry the action. If it keeps failing, restart the app and check Settings diagnostics.")) } + + @Test("Conflict recovery error is fixed path-free and never retryable (#150)") + func conflictRecoveryUnavailableIsReadOnly() { + let error = SyncUserError.from(rawMessage: "vaultsync-conflict-recovery-unavailable") + + #expect(error.category == .conflictRetentionSafetyStop) + #expect(error.title == L10n.tr("Conflict Recovery Unavailable")) + #expect(error.message == L10n.tr("Conflict recovery actions are not available in this version.")) + #expect(error.remediation == L10n.tr("Review the copies that are still available here. Leave files unchanged; VaultSync cannot run a recovery action in this version.")) + #expect(error.technicalDetails == nil) + #expect(SyncUserError.troubleshootingURL(for: error) == nil) + + let rendered = "\(error.id)|\(error.userVisibleDescription)".lowercased() + for forbidden in [ + "vaultsync-conflict-recovery-unavailable", + "retry", + "success", + "renamed", + "removed", + "discarded", + ] { + #expect(!rendered.contains(forbidden)) + } + } + + @Test("Global receive safety copy states read-only policy without an unproved block claim (#150)") + func receiveSafetyCopyIsNeutralIssue150() { + let stopped = SyncUserError.conflictSafetyError(for: .stopped) + let unknown = SyncUserError.conflictSafetyError(for: .unknown) + + #expect(stopped.message == L10n.tr("VaultSync keeps receive-capable vaults read-only in this version.")) + #expect(unknown.message == L10n.tr("VaultSync keeps receive-capable vaults read-only in this version.")) + for error in [stopped, unknown] { + #expect(error.remediation == L10n.tr("You can review available status and conflict copies, but receive-side changes and conflict recovery are unavailable.")) + let rendered = error.userVisibleDescription.lowercased() + for forbidden in [ + "blocked an unsafe conflict change", + "before it could", + "affected copies", + "success", + "retry", + ] { + #expect(!rendered.contains(forbidden)) + } + } + } } @Suite("Keep Both collision error mapping (#144)") @@ -72,7 +140,7 @@ struct KeepBothCollisionErrorMappingTests { #expect(error.category == .config) #expect(error.title == L10n.tr("Conflict Resolution Failed")) #expect(error.message == L10n.tr("Keep Both did not change any files because the new copy name is already in use.")) - #expect(error.remediation == L10n.tr("Rename the existing copy in Files, then try Keep Both again.")) + #expect(error.remediation == L10n.tr("Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available.")) #expect(error.technicalDetails == raw) } @@ -84,7 +152,7 @@ struct KeepBothCollisionErrorMappingTests { #expect(error.category == .fileAccess) #expect(error.title == L10n.tr("Conflict Resolution Failed")) #expect(error.message == L10n.tr("Keep Both did not change any files because this storage location does not support safe renaming.")) - #expect(error.remediation == L10n.tr("Resolve this conflict manually in Files without replacing either file.")) + #expect(error.remediation == L10n.tr("Leave both copies unchanged. Do not repeat this action until a separately verified recovery is available.")) #expect(error.technicalDetails == raw) } } @@ -160,7 +228,7 @@ struct FolderMarkerMissingMappingTests { } } -@Suite("Rescan CTA availability under marker loss (#65)") +@Suite("Rescan CTA availability under marker loss and unknown conflict safety (#65, #150)") struct RescanCTAAvailabilityTests { private func errorStatus(message: String) -> SyncthingManager.FolderStatusInfo { SyncthingManager.FolderStatusInfo(payload: .init( @@ -192,16 +260,16 @@ struct RescanCTAAvailabilityTests { #expect(manager.hasRescanableFolderErrors == false) } - @Test("A non-marker folder error keeps the rescan path available") + @Test("An unclassified folder error hides the rescan path (#150)") @MainActor - func otherErrorsStayRescanable() { + func unknownErrorEvidenceIsNotRescanable() { let manager = SyncthingManager() manager._testSetFolderStatuses([ "vault-a": errorStatus(message: FolderMarkerMissingMappingTests.rawEngineText), "vault-b": errorStatus(message: "database is locked"), ]) - #expect(manager.hasRescanableFolderErrors) + #expect(manager.hasRescanableFolderErrors == false) } } diff --git a/ios/VaultSyncTests/WidgetCompletionWriteTests.swift b/ios/VaultSyncTests/WidgetCompletionWriteTests.swift index f5f890d..652b855 100644 --- a/ios/VaultSyncTests/WidgetCompletionWriteTests.swift +++ b/ios/VaultSyncTests/WidgetCompletionWriteTests.swift @@ -24,7 +24,7 @@ struct WidgetCompletionWriteTests { id: "vault-a", label: "Vault A", path: "/tmp/widget77/vault-a", - type: "sendreceive", + type: "sendonly", paused: false, deviceIDs: [] ), @@ -32,7 +32,7 @@ struct WidgetCompletionWriteTests { return manager } - private func status(state: String) -> SyncthingManager.FolderStatusInfo { + private func status(state: String, errorReason: String? = nil) -> SyncthingManager.FolderStatusInfo { SyncthingManager.FolderStatusInfo(payload: .init( state: state, stateChanged: "2026-07-07T10:00:00Z", @@ -44,7 +44,7 @@ struct WidgetCompletionWriteTests { needBytes: 0, needFiles: 0, inProgressBytes: 0, - errorReason: nil, + errorReason: errorReason, errorMessage: nil, errorPath: nil, errorChanged: nil @@ -84,4 +84,30 @@ struct WidgetCompletionWriteTests { manager._testWriteWidgetSnapshot() #expect(manager._testLastWrittenWidgetSnapshot() == afterCompletion) } + + @MainActor + @Test("Safety stop and unknown status never stamp completion metrics or green (#150)") + func conflictSafetyDoesNotWriteCompletion() throws { + for reason in [ + ConflictSafetyPolicy.stoppedReason, + ConflictSafetyPolicy.folderErrorEvidenceUnavailableReason, + ] { + let manager = makeManager() + let syncing = ["vault-a": status(state: "syncing")] + let clearIdle = ["vault-a": status(state: "idle")] + manager._testUpdateWidgetSyncMetrics(previousStatuses: [:], newStatuses: syncing) + manager._testUpdateWidgetSyncMetrics(previousStatuses: syncing, newStatuses: clearIdle) + let lastSuccess = try #require(manager._testLastWrittenWidgetSnapshot()) + + manager._testUpdateWidgetSyncMetrics(previousStatuses: clearIdle, newStatuses: syncing) + let blockedIdle = ["vault-a": status(state: "idle", errorReason: reason)] + manager._testUpdateWidgetSyncMetrics(previousStatuses: syncing, newStatuses: blockedIdle) + let blocked = try #require(manager._testLastWrittenWidgetSnapshot()) + + #expect(blocked.status != SyncStatus.synced.wireValue) + #expect(blocked.lastSyncTime == lastSuccess.lastSyncTime) + #expect(blocked.lastSyncDuration == lastSuccess.lastSyncDuration) + #expect(blocked.filesSynced == lastSuccess.filesSynced) + } + } } diff --git a/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift b/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift index c6af853..b333f3f 100644 --- a/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift +++ b/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift @@ -61,14 +61,14 @@ struct WidgetSnapshotStatusTests { #expect(derive(engineRunning: false) == .starting) } - @Test("Clean state stays synced") + @Test("Clean configured state stays synced while folderless state stays neutral (#150)") func cleanStateIsSynced() { #expect(derive() == .synced) - #expect(derive(hasSyncFolders: false) == .synced) + #expect(derive(hasSyncFolders: false) == .starting) } // Structural guarantee of decision 012: the widget tier IS the header - // cascade with the vault tiers pinned "armed" — a new issue kind that + // cascade with vault access and detection pinned true — a new issue kind that // reaches the header can never miss the widget. @Test("Widget tier equals the header cascade for every input combination") func matchesHeaderCascade() { diff --git a/ios/project.yml b/ios/project.yml index 4091c80..c476adc 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -85,7 +85,7 @@ targets: # (Syncthing's multicast *discovery* additionally needs the restricted # com.apple.developer.networking.multicast entitlement — Apple-approved # for this App ID and enabled in the entitlements below; see docs/architecture.md.) - NSLocalNetworkUsageDescription: "VaultSync connects directly to your other devices on the same network so your vaults sync instantly, without a detour through the internet." + NSLocalNetworkUsageDescription: "VaultSync uses the local network to connect directly to your other devices for status checks and Send Only uploads." # NOTE: NSAppTransportSecurity is deliberately NOT set, so shipping (Release) # builds keep the strictest ATS posture. The production app only talks HTTPS to # the relay. The DEBUG mock-relay loop targets http://127.0.0.1 (loopback), From 9ef4eec9c4475fc8b6631ddc415c7101ecfa712b Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Sun, 30 Aug 2026 15:26:50 +0200 Subject: [PATCH 2/2] fix(sync): preserve bounded conflict inspection compatibility (#150) Restore the historical bridge wire shapes while adding explicit V2 complete, partial, and unavailable evidence. Reject stale filter scans, keep retained conflict warnings uniquely identifiable, and isolate process- wide Bridge test locations. The #150 receive-side stop remains unchanged; this commit does not authorize merge or release while #169 is open. Refs #150, #167, #169. --- CHANGELOG.md | 4 +- README.md | 12 + docs/architecture.md | 43 +- ...33-conflict-recovery-is-inspection-only.md | 1 + docs/relay-spec.md | 8 + docs/sync-filters-ux.md | 10 +- docs/troubleshooting.md | 37 +- go/bridge/conflicts.go | 229 +++++++--- go/bridge/conflicts_test.go | 295 ++++++++++++- go/bridge/folderscan.go | 115 ++++- go/bridge/folderscan_test.go | 148 ++++++- go/bridge/issue150_database_open_test.go | 18 +- go/bridge/syncthing_test.go | 67 ++- ios/VaultSync/Models/DetectedPattern.swift | 9 +- .../Services/BackgroundSyncService.swift | 13 +- .../Services/FilterScanGeneration.swift | 81 ++++ .../Services/SyncBridgeService.swift | 17 +- ios/VaultSync/Services/SyncthingManager.swift | 105 +++-- ios/VaultSync/Views/IgnorePatternsView.swift | 61 ++- .../Views/SyncFilterRecommendationSheet.swift | 64 ++- ios/VaultSync/Views/SyncIssuesView.swift | 43 +- .../BackgroundWidgetStatusTests.swift | 5 + ...flictRetentionSafetyIntegrationTests.swift | 407 +++++++++++++++++- notify/README.md | 7 + 24 files changed, 1566 insertions(+), 233 deletions(-) create mode 100644 ios/VaultSync/Services/FilterScanGeneration.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdfedf..baf5b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ All notable changes to VaultSync are documented here. ### Fixed -- **Receive-capable vaults now stop before automatic local changes** ([#150](https://github.com/psimaker/vaultsync/issues/150), [#167](https://github.com/psimaker/vaultsync/issues/167)) — In 2.0.2, Send & Receive, Receive Only, and Receive Encrypted vaults retain remote change information for inspection but do not scan, watch, download, clean versions, or change local vault/index data; Send Only keeps its existing behavior and explicit Sync Filters. Explicit pause, sharing, unsharing, and removal remain available, while path, filter, and rescan changes stay stopped for receive-capable vaults. If internal database validation cannot prove a clean recognized state, VaultSync stops instead of repairing it automatically. VaultSync lets you inspect whichever conflict copies are still available, but Keep This, Keep Other, Keep Both, and Always Skip cannot rename, replace, delete, ignore, or rescan conflict files. Creating a new vault or accepting a new shared vault is unavailable in 2.0.2. Reopening an existing vault performs no delayed default-filter write or rescan. +- **Receive-capable vaults now stop before automatic local changes** ([#150](https://github.com/psimaker/vaultsync/issues/150), [#167](https://github.com/psimaker/vaultsync/issues/167), [#169](https://github.com/psimaker/vaultsync/issues/169)) — In 2.0.2, Send & Receive, Receive Only, and Receive Encrypted vaults retain remote change information for inspection but do not scan, watch, download, clean versions, or change local vault/index data. VaultSync 1.8.2, 2.0.0, and 2.0.1 created and accepted regular vaults as Send & Receive, so those existing vaults are frozen after upgrade: they neither download server changes nor index and upload new iPhone edits. Cloud Relay subscription recognition, provisioning, status, and wake-ups remain available, but a wake-up cannot pull changes into a frozen vault. Existing Send Only keeps its scan, upload, filter, and rescan behavior. Explicit pause, sharing, unsharing, and removal remain available, while path, filter, and rescan changes stay stopped for receive-capable vaults. If internal database validation cannot prove a clean recognized state, VaultSync stops instead of repairing it automatically. VaultSync lets you inspect whichever conflict copies are still available, but Keep This, Keep Other, Keep Both, and Always Skip cannot rename, replace, delete, ignore, or rescan conflict files. Creating a new vault or accepting a new shared vault is unavailable in 2.0.2. Reopening an existing vault performs no delayed default-filter write or rescan. +- **Conflict inspection remains honest and bounded in large vaults** ([#150](https://github.com/psimaker/vaultsync/issues/150)) — VaultSync keeps a hard limit on visited filesystem entries plus a separate limit on collected conflicts. A bounded partial result remains visibly incomplete and is combined with previously visible conflict copies; only a verified complete empty inspection may clear them. The new versioned inspection bridge is additive, while the historical bridge entry points retain their earlier response formats for upgrade compatibility. +- **Sync Filter scans no longer apply stale results after a safety-state change** ([#150](https://github.com/psimaker/vaultsync/issues/150)) — Every scan now has its own generation and must still match the current folder and a fresh clear Send Only safety state before its results or automatic selections appear. The Go bridge independently rejects every non-SendOnly folder before filesystem inspection. - **Cloud Relay no longer continues from malformed saved Relay device IDs** ([#161](https://github.com/psimaker/vaultsync/issues/161)) — VaultSync reports that Cloud Relay provisioning did not complete and sends no provisioning request, without rewriting or deleting the stored value. Valid existing JSON and legacy records remain unchanged. - **Push and Cloud Relay registration details now survive failed secure-storage updates** ([#148](https://github.com/psimaker/vaultsync/issues/148)) — VaultSync keeps the last valid value when a replacement cannot be saved and reports the failure instead of continuing as if registration succeeded. - **Folder access now stays intact when reconnecting or syncing in the background** ([#147](https://github.com/psimaker/vaultsync/issues/147)) — reselecting the same Obsidian folder no longer accumulates access claims. Switching folders takes effect only after the new location is readable, scanned, and its permission is saved; any failure keeps the previous folder connected. Background runs release only their own access on completion, restart, or cancellation. diff --git a/README.md b/README.md index 85a56c4..fba1bc6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ Your notes sync peer-to-peer over Syncthing, straight into Obsidian's iOS sandbo --- +> [!IMPORTANT] +> **VaultSync 2.0.2 is a temporary data-safety containment release.** Vaults +> created or accepted by VaultSync 1.8.2, 2.0.0, and 2.0.1 are normally +> Send & Receive. After upgrading, those vaults are frozen: they neither +> download server changes nor scan and upload new iPhone edits. Existing Send +> Only folders continue to upload. New vault creation, share acceptance, and +> conflict recovery are unavailable. Cloud Relay can still wake the app and +> report status, but cannot download changes into a frozen vault. A previous +> unclean engine shutdown can also make 2.0.2 refuse to start rather than alter +> uncertain stored state. VaultSync does not automatically repair or convert a +> live folder, and does not weaken this safety boundary. + ## 🔭 Why VaultSync - **Peer-to-peer & private** — syncs directly between your own devices over [Syncthing](https://syncthing.net/). No note cloud, no account, no tracking. diff --git a/docs/architecture.md b/docs/architecture.md index 6aa0b36..d574fe5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,11 +17,23 @@ VaultSync embeds Syncthing's Go reference implementation as an iOS library via g └─────────────────────────────────┘ ``` +> [!IMPORTANT] +> VaultSync 2.0.2 is a temporary data-safety containment release. Existing +> Send Only folders keep their scan, index, filter, rescan, and upload behavior. +> Send & Receive, Receive Only, and Receive Encrypted folders do not pull, scan, +> watch, clean versions, or index new local edits. Because VaultSync 1.8.2, +> 2.0.0, and 2.0.1 created and accepted regular vaults as Send & Receive, those +> vaults are frozen after upgrade: they neither download server changes nor +> upload new iPhone edits. New vault creation and share acceptance are also +> unavailable. Cloud Relay can still deliver and report a wake-up, but cannot +> pull changes into a frozen folder. VaultSync never converts a live folder to +> Send Only automatically and does not weaken this boundary to restore service. + ## 🔄 Sync strategy -- **Foreground** — Syncthing runs unrestricted: immediate, continuous sync. -- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGProcessingTask` (overnight catch-up: multi-minute budget while charging with network) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish. -- **Push (Cloud Relay)** — optional. Near-realtime `server → iPhone` wake-ups via APNs silent push. See [relay-spec.md](relay-spec.md). +- **Foreground** — in 2.0.2, existing Send Only folders run normally; receive-capable folders remain stopped before local or receive-side mutation. +- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGProcessingTask` (overnight catch-up: multi-minute budget while charging with network) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets eligible Send Only work finish; a protected receive-capable folder reports failure rather than a false success. +- **Push (Cloud Relay)** — optional. APNs can still request a background wake-up in 2.0.2, but a wake-up cannot pull into a protected receive-capable folder. See [relay-spec.md](relay-spec.md). VaultSync is intentionally **asymmetric**: @@ -32,6 +44,29 @@ VaultSync is intentionally **asymmetric**: Cloud Relay is a `server → iPhone` *acceleration* path, not a guarantee of symmetric real-time background sync. +### Versioned bridge inspection contracts + +Bridge ABI compatibility and inspection truth are separate requirements: + +- `GetConflictFilesJSON` remains the historical JSON-array entry point. It + returns an array for every outcome so an older app/bridge pair keeps its wire + shape; it cannot distinguish unavailable from verified empty. +- `ReadFileContent` remains the historical raw-text or `error:` entry point. + Its fixed error contains no path, filename, folder, or driver detail. +- Current Swift uses the additive `GetConflictFilesInspectionJSONV2` and + `ReadFileContentJSONV2` entry points. Their envelopes carry `version: 2` and + distinguish complete empty, complete results, bounded partial results, and + unavailable inspection. Unknown, legacy, malformed, or contradictory + envelopes fail closed. +- Conflict inspection retains a hard visited-entry bound and a separate + collected-conflict bound. A partial result keeps every conflict found so far, + is unioned with previously visible copies, and always surfaces an incomplete + warning; only a complete empty result may clear cached conflicts. +- `ScanFolderForKnownPatterns` returns explicit `complete` evidence and checks + the live configured folder type before any filesystem inspection. Only an + exact Send Only folder is authorized; stopped, unknown, or receive-capable + states return a fixed path-free unavailable result. + ### Relay and sync proof hierarchy VaultSync models proof as independent fields, never as one derived “sync @@ -277,7 +312,7 @@ Minimal API exported via gomobile. Only primitives + `string` + `[]byte` cross t - **Folders:** `AddFolder`, `RemoveFolder`, `RescanFolder`, `GetFoldersJSON`, `ShareFolderWithDevice`, `UnshareFolderFromDevice` - **Status & config:** `GetFolderStatusJSON`, `GetConnectionsJSON`, `GetConfigJSON`, `SetDiscoveryEnabled` - **Pending shares:** `GetPendingFoldersJSON`, `AcceptPendingFolder` -- **Conflicts:** `GetConflictFilesJSON`, `ResolveConflict`, `KeepBothConflict`, `ReadFileContent`, `RemoveConflictFilesForOriginal` +- **Conflicts:** `GetConflictFilesJSON`, `GetConflictFilesInspectionJSONV2`, `ResolveConflict`, `KeepBothConflict`, `ReadFileContent`, `ReadFileContentJSONV2`, `RemoveConflictFilesForOriginal` - **Filters:** `GetFolderIgnores`, `SetFolderIgnores`, `ScanFolderForKnownPatterns` - **Events:** `GetEventsSince`, `EventStreamGeneration` diff --git a/docs/decisions/033-conflict-recovery-is-inspection-only.md b/docs/decisions/033-conflict-recovery-is-inspection-only.md index 1c684f3..3db4cf0 100644 --- a/docs/decisions/033-conflict-recovery-is-inspection-only.md +++ b/docs/decisions/033-conflict-recovery-is-inspection-only.md @@ -2,6 +2,7 @@ - Context: Existing recovery actions can delete, replace, rename, ignore, or rescan conflict files without a proven byte-preserving recovery doctrine (#150/#167). - Decision: `ResolveConflict`, `KeepBothConflict`, and `RemoveConflictFilesForOriginal` retain their ABI signatures but return one stable, path-free recovery-unavailable error before any runtime, filesystem, temporary-file, database, filter, or rescan access. +- Inspection ABI: Historical conflict-list and file-read entry points retain their wire shapes; additive `*V2` entry points carry versioned complete, partial, and unavailable evidence, and current Swift fails closed on every other shape. - UI: Conflict copies still present can be inspected, but 2.0.2 makes no retention guarantee and exposes no executable recovery, retry, skip, confirmation, or success flow. - Scope: `AutoResolveStateConflicts` remains non-mutating; no folder pause, configuration rewrite, persisted-state migration, or automatic reacceptance is introduced. - Why: Inspection adds no recovery mutation, while an unproven action can silently propagate lost bytes to every peer. diff --git a/docs/relay-spec.md b/docs/relay-spec.md index d8e7fd0..6518e41 100644 --- a/docs/relay-spec.md +++ b/docs/relay-spec.md @@ -2,6 +2,14 @@ > **Status:** Cloud Relay 1.3.2 and VaultSync 2.0.1 are in production, and helper 2.0.2 is published. Relay 1.3 provisioning requires a verified StoreKit signed transaction and gives exact pre-existing legacy registrations a bounded compatibility window through October 31, 2026. Helper publication state is determined only by the newest public `notify-v*` release and its exact manifest; `notify-v1.8.0` remains the fixed rollback baseline for helper 2.0.2. A Relay-observed signal proves only accepted Relay processing: not helper identity, APNs delivery, background start, local data progress, upload, download, or a roundtrip. Existing Relay v1 provisioning, trigger, and push contracts remain unchanged. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. +> [!IMPORTANT] +> VaultSync 2.0.2 keeps entitlement verification, provisioning, Relay status, +> and APNs wake-up handling, but its #150 containment boundary prevents all +> receive-capable folders from pulling. A wake-up therefore cannot deliver a +> server change into a regular vault created or accepted by VaultSync 1.8.2, +> 2.0.0, or 2.0.1. It also cannot make those folders scan and upload new iPhone +> edits. Existing Send Only folders remain eligible for upload work. + ## Overview Push-notification service that forwards Syncthing file-change events to iOS devices via APNs. It solves the core iOS limitation — no real-time background sync — by waking the app on demand instead of polling. diff --git a/docs/sync-filters-ux.md b/docs/sync-filters-ux.md index 8962b9d..ac170d0 100644 --- a/docs/sync-filters-ux.md +++ b/docs/sync-filters-ux.md @@ -135,10 +135,12 @@ error before accessing the folder or `.stignore`. Normal explicit Sync Filters remain available only for Send Only folders; receive-capable and unknown modes stop before filter access or mutation. -Previously recorded filter pairs remain stored and editable in Sync Filters; -there is no migration or automatic rewrite. Their representation as one row -with a `+ conflict copies` caption remains unchanged. This preserves an explicit -past choice without treating it as consent for a new conflict recovery action. +Previously recorded filter pairs remain stored without migration or automatic +rewrite. They remain visible and editable only for an existing Send Only folder +whose current safety state is clear. Receive-capable, unknown, and safety-stopped +folders expose no filter access. The pairs' representation as one row with a +`+ conflict copies` caption remains unchanged. This preserves an explicit past +choice without treating it as consent for a new conflict recovery action. ## 6.5 Multi-vault setups diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3c178d9..3253636 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -183,10 +183,10 @@ you're subscribed but wake-ups never arrive; or per-device provisioning says **Fix:** 1. If you moved or renamed the folder: move it back to its original place and name — syncing resumes on its own. -2. If the folder is gone or was replaced: remove the vault in VaultSync on this iPhone, then accept its share again under **Pending Shares** — it syncs into a fresh folder. -3. If notes are missing on this iPhone, they are still on your other synced devices — re-accepting the share in step 2 brings them back. +2. In VaultSync 2.0.2, do not remove the vault as a recovery attempt. New share acceptance is unavailable, so removal would leave no supported way to restore the share in this release. +3. Keep the intact copies on your other devices unchanged. VaultSync never repoints, recreates, or repopulates the missing folder automatically. -VaultSync never moves, recreates, or deletes folders on its own, and a rescan cannot fix this — recovery here is always your manual decision. +VaultSync never moves, recreates, or deletes folders on its own, and a rescan cannot fix this. A future recovery path must remain an explicit manual decision rather than an automatic repair. --- @@ -198,20 +198,43 @@ VaultSync re-derives every vault's location from your Obsidian folder on launch, **Fix:** 1. Tap **Reconnect to Obsidian** and re-select the same Obsidian folder in the Files picker. -2. Keep VaultSync in the foreground and run a rescan. -3. If a vault points at storage that's truly gone, use **Remove this vault** (it only stops syncing on this iPhone — your other devices keep their notes). +2. For an existing Send Only vault whose safety state is clear, keep VaultSync in the foreground and run a rescan. Receive-capable vaults remain frozen in 2.0.2. +3. If a vault points at storage that is truly gone, do not remove it as a 2.0.2 recovery attempt: this release cannot accept its share again. Preserve the intact copies on your other devices. --- ## Background Sync Not Working -iOS controls background time and may delay or skip it. Cloud Relay makes `server → iPhone` feel near-realtime; `iPhone → server` is reliable only when VaultSync is open. +### VaultSync 2.0.2 containment + +VaultSync 2.0.2 intentionally freezes every Send & Receive, Receive Only, and +Receive Encrypted vault before download, scan, watch, local indexing, filter, or +rescan work. Regular vaults created or accepted by VaultSync 1.8.2, 2.0.0, and +2.0.1 are Send & Receive, so after upgrading they neither download server +changes nor upload new iPhone edits. Cloud Relay may still show a received +wake-up, but the protected vault remains frozen and the background run reports +failure rather than claiming success. + +Do not remove and re-accept the vault, edit Syncthing's database, or change its +folder type as a workaround. VaultSync has no approved automatic recovery or +conversion path in 2.0.2. Existing Send Only folders continue to work when their +safety state is clear. + +A previous unclean engine shutdown can also make 2.0.2 refuse to start when its +stored sync state is ambiguous. VaultSync leaves that state untouched instead +of attempting an automatic repair; this release has no supported self-service +recovery for that stop. + +iOS controls background time and may delay or skip it. Outside the 2.0.2 +receive containment, Cloud Relay can make `server → iPhone` feel near-realtime; +`iPhone → server` is most reliable with VaultSync open. In 2.0.2 the following +steps can help only an existing Send Only vault whose safety state is clear. **Looks like:** last sync goes stale; sync works in the foreground but not when the app is closed. **Fix:** 1. Open VaultSync and run a manual rescan. -2. Clear any **Sync Issues** first (folder errors, pending shares, disconnected peers). +2. Clear any actionable **Sync Issues** first. Pending shares and receive-capable vaults have no executable recovery in 2.0.2. 3. Reconnect the Obsidian folder if access warnings appear. 4. For relay users, confirm `vaultsync-notify --doctor` is green and **Last Wake-up Received** is recent. 5. Re-check the **Last sync** timestamp after the next background window. diff --git a/go/bridge/conflicts.go b/go/bridge/conflicts.go index 9323406..b8151ab 100644 --- a/go/bridge/conflicts.go +++ b/go/bridge/conflicts.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "regexp" @@ -34,9 +35,13 @@ type ConflictFile struct { // Example: notes.sync-conflict-20260406-143022-ABC1234.md var conflictPattern = regexp.MustCompile(`^(.+)\.sync-conflict-(\d{8}-\d{6})-([A-Z0-9]{7})(\..+)$`) -// maxConflictScan limits the number of files examined during conflict detection -// to prevent excessive I/O on very large vaults. -const maxConflictScan = 10000 +const ( + // Keep both limits independent: the visit ceiling is the I/O hard bound, + // while the collection ceiling bounds the response retained in memory. + maxConflictScanVisitedEntries = 10000 + maxConflictScanCollectedConflicts = 10000 + conflictInspectionJSONVersion = 2 +) const keepBothTargetExistsError = "keep both target already exists" @@ -95,56 +100,115 @@ func systemKeepBothFileOperations() keepBothFileOperations { } } -// GetConflictFilesJSON scans the folder's directory for .sync-conflict-* files. -// It returns a JSON array only after a complete bounded walk; an unavailable, -// failed, or truncated inspection returns the stable path-free error instead. -func GetConflictFilesJSON(folderID string) string { +type conflictInspectionLimits struct { + maxVisitedEntries int + maxCollectedConflicts int +} + +type conflictInspectionWalk func(string, fs.WalkDirFunc) error + +type conflictInspectionResultV2 struct { + Version int `json:"version"` + Conflicts []ConflictFile `json:"conflicts"` + Complete bool `json:"complete"` + Error string `json:"error,omitempty"` +} + +func unavailableConflictInspectionV2() conflictInspectionResultV2 { + return conflictInspectionResultV2{ + Version: conflictInspectionJSONVersion, + Conflicts: []ConflictFile{}, + Complete: false, + Error: conflictInspectionUnavailableError, + } +} + +func configuredConflictInspection(folderID string) conflictInspectionResultV2 { folders := getFolderConfigs() if folders == nil { - return conflictInspectionUnavailableError + return unavailableConflictInspectionV2() } folder, exists := folders[folderID] if !exists { - return conflictInspectionUnavailableError + return unavailableConflictInspectionV2() } + return inspectConflictFiles(folder.Path, conflictInspectionLimits{ + maxVisitedEntries: maxConflictScanVisitedEntries, + maxCollectedConflicts: maxConflictScanCollectedConflicts, + }, filepath.WalkDir) +} - var conflicts []ConflictFile - scanned := 0 - truncated := false - - walkErr := filepath.WalkDir(folder.Path, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { +func inspectConflictFiles(root string, limits conflictInspectionLimits, walk conflictInspectionWalk) conflictInspectionResultV2 { + result := conflictInspectionResultV2{ + Version: conflictInspectionJSONVersion, + Conflicts: []ConflictFile{}, + Complete: true, + } + if limits.maxVisitedEntries <= 0 || limits.maxCollectedConflicts <= 0 || walk == nil { + return unavailableConflictInspectionV2() + } + + rootAvailable := false + partial := false + visitedEntries := 0 + walkErr := walk(root, func(path string, entry fs.DirEntry, entryErr error) error { + if !rootAvailable { + if entryErr != nil { + return entryErr + } + if entry == nil || filepath.Clean(path) != filepath.Clean(root) || !entry.IsDir() { + return fs.ErrInvalid + } + rootAvailable = true return nil } + if entryErr != nil || entry == nil { + partial = true + return fs.SkipAll + } - scanned++ - if scanned > maxConflictScan { - truncated = true - return filepath.SkipAll + visitedEntries++ + stopAfterEntry := visitedEntries >= limits.maxVisitedEntries + if entry.IsDir() { + if stopAfterEntry { + partial = true + return fs.SkipAll + } + return nil } - name := d.Name() + name := entry.Name() if !strings.Contains(name, ".sync-conflict-") { + if stopAfterEntry { + partial = true + return fs.SkipAll + } return nil } matches := conflictPattern.FindStringSubmatch(name) if matches == nil { + if stopAfterEntry { + partial = true + return fs.SkipAll + } return nil } + if len(result.Conflicts) >= limits.maxCollectedConflicts { + partial = true + return fs.SkipAll + } baseName := matches[1] date := matches[2] shortID := matches[3] ext := matches[4] - relPath, err := filepath.Rel(folder.Path, path) + relPath, err := filepath.Rel(root, path) if err != nil { - return err + partial = true + return fs.SkipAll } dir := filepath.Dir(relPath) @@ -153,30 +217,56 @@ func GetConflictFilesJSON(folderID string) string { originalRel = filepath.Join(dir, originalRel) } - conflicts = append(conflicts, ConflictFile{ + result.Conflicts = append(result.Conflicts, ConflictFile{ OriginalPath: originalRel, ConflictPath: relPath, ConflictDate: date, DeviceShortID: shortID, }) + if stopAfterEntry { + partial = true + return fs.SkipAll + } return nil }) - if walkErr != nil || truncated { - return conflictInspectionUnavailableError + if !rootAvailable || (visitedEntries == 0 && (partial || walkErr != nil)) { + return unavailableConflictInspectionV2() + } + if walkErr != nil { + partial = true } + result.Complete = !partial + return result +} - if conflicts == nil { - conflicts = []ConflictFile{} +func marshalConflictInspectionV2(result conflictInspectionResultV2) string { + data, err := json.Marshal(result) + if err != nil { + data, _ = json.Marshal(unavailableConflictInspectionV2()) } + return string(data) +} - data, err := json.Marshal(conflicts) +// GetConflictFilesJSON preserves the historical bridge contract: every result +// is a JSON array. New callers must use GetConflictFilesInspectionJSONV2 so an +// unavailable or partial inspection cannot be mistaken for verified empty. +func GetConflictFilesJSON(folderID string) string { + result := configuredConflictInspection(folderID) + data, err := json.Marshal(result.Conflicts) if err != nil { - return conflictInspectionUnavailableError + return "[]" } return string(data) } +// GetConflictFilesInspectionJSONV2 returns the versioned conflict-inspection +// contract. Complete=false with no error is a bounded partial list; +// Complete=false with the stable error is completely unavailable. +func GetConflictFilesInspectionJSONV2(folderID string) string { + return marshalConflictInspectionV2(configuredConflictInspection(folderID)) +} + // safePath validates that relPath stays within folderRoot after cleaning. // Returns the absolute cleaned path or an error if it escapes the root. func safePath(folderRoot, relPath string) (string, error) { @@ -233,44 +323,65 @@ func keepBothConflictFile(conflictPath, conflictFileName string, ops keepBothFil return "" } -// ReadFileContent reads a text file within a folder and returns a JSON envelope. -// The exported Go signature stays ABI-compatible, while the envelope keeps an -// empty file and legitimate "error:" content distinct from an unavailable -// inspection. Failures expose only the fixed path-free code. -func ReadFileContent(folderID, relPath string) string { - type result struct { - Content *string `json:"content,omitempty"` - Error string `json:"error,omitempty"` - } - emit := func(value result) string { - data, err := json.Marshal(value) - if err != nil { - return `{"error":"vaultsync-conflict-inspection-unavailable"}` - } - return string(data) - } - unavailable := func() string { - return emit(result{Error: conflictInspectionUnavailableError}) - } - +func readFileContent(folderID, relPath string) (string, bool) { folders := getFolderConfigs() if folders == nil { - return unavailable() + return "", false } folder, exists := folders[folderID] if !exists { - return unavailable() + return "", false } absPath, err := safePath(folder.Path, relPath) if err != nil { - return unavailable() + return "", false } data, err := os.ReadFile(absPath) if err != nil { - return unavailable() + return "", false + } + return string(data), true +} + +// ReadFileContent preserves the historical raw-text bridge contract. Failures +// retain the legacy error: prefix but expose only the fixed path-free code. +func ReadFileContent(folderID, relPath string) string { + content, ok := readFileContent(folderID, relPath) + if !ok { + return "error:" + conflictInspectionUnavailableError } - content := string(data) - return emit(result{Content: &content}) + return content +} + +type readFileContentResultV2 struct { + Version int `json:"version"` + Content *string `json:"content,omitempty"` + Error string `json:"error,omitempty"` +} + +func unavailableReadFileContentV2() readFileContentResultV2 { + return readFileContentResultV2{ + Version: conflictInspectionJSONVersion, + Error: conflictInspectionUnavailableError, + } +} + +// ReadFileContentJSONV2 returns an unambiguous versioned envelope so empty +// files and legitimate content beginning with error: remain distinguishable. +func ReadFileContentJSONV2(folderID, relPath string) string { + content, ok := readFileContent(folderID, relPath) + result := unavailableReadFileContentV2() + if ok { + result = readFileContentResultV2{ + Version: conflictInspectionJSONVersion, + Content: &content, + } + } + data, err := json.Marshal(result) + if err != nil { + data, _ = json.Marshal(unavailableReadFileContentV2()) + } + return string(data) } // ResolveConflict is retained for gomobile ABI compatibility. Conflict @@ -358,7 +469,7 @@ func closeConflictTempAfterError(tempFile conflictTempFile, operation string, op // Symmetric with GetConflictFilesJSON's JSON-return style — keeps the gomobile // surface uniform (no tuple returns across the bridge). func RemoveConflictFilesForOriginal(folderID, originalPath string) string { - return `{"removed":0,"error":"vaultsync-conflict-recovery-unavailable"}` + return fmt.Sprintf(`{"removed":0,"error":%q}`, conflictRecoveryUnavailableError) } // AutoResolveStateConflicts is retained for gomobile ABI compatibility but no diff --git a/go/bridge/conflicts_test.go b/go/bridge/conflicts_test.go index a845c93..a78295b 100644 --- a/go/bridge/conflicts_test.go +++ b/go/bridge/conflicts_test.go @@ -3,7 +3,9 @@ package bridge import ( "bytes" "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "reflect" @@ -16,6 +18,37 @@ import ( const issue150ConflictRecoveryUnavailable = "vaultsync-conflict-recovery-unavailable" +type issue150ConflictInspectionV2Payload struct { + Version int `json:"version"` + Conflicts []ConflictFile `json:"conflicts"` + Complete bool `json:"complete"` + Error string `json:"error"` +} + +type issue150ReadFileContentV2Payload struct { + Version int `json:"version"` + Content *string `json:"content"` + Error string `json:"error"` +} + +func issue150DecodeConflictInspectionV2(t *testing.T, raw string) issue150ConflictInspectionV2Payload { + t.Helper() + var result issue150ConflictInspectionV2Payload + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("decode V2 conflict inspection: %v (raw: %q)", err, raw) + } + return result +} + +func issue150DecodeReadFileContentV2(t *testing.T, raw string) issue150ReadFileContentV2Payload { + t.Helper() + var result issue150ReadFileContentV2Payload + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("decode V2 file-content inspection: %v (raw: %q)", err, raw) + } + return result +} + type issue150RecoveryEntry struct { Mode os.FileMode ModTime time.Time @@ -327,9 +360,232 @@ func TestGetConflictFilesJSON(t *testing.T) { t.Error("subdirectory conflict not found") } - // An unavailable folder must not be confused with a verified empty scan. - if got := GetConflictFilesJSON("nonexistent"); got != conflictInspectionUnavailableError { - t.Errorf("nonexistent folder = %q, want fixed unavailable code", got) + // The historical endpoint always returns an array, including unavailable. + if got := GetConflictFilesJSON("nonexistent"); got != "[]" { + t.Errorf("nonexistent legacy folder = %q, want []", got) + } +} + +func TestIssue150LegacyConflictInspectionWireShapeRemainsJSONArray(t *testing.T) { + StopSyncthing() + for _, raw := range []string{ + GetConflictFilesJSON("issue150-stopped-legacy-inspection"), + } { + var conflicts []ConflictFile + if err := json.Unmarshal([]byte(raw), &conflicts); err != nil { + t.Fatalf("legacy stopped-engine response is not a JSON array: %v (raw: %q)", err, raw) + } + } + + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + if raw := GetConflictFilesJSON("issue150-unknown-legacy-inspection"); raw != "[]" { + t.Fatalf("legacy unknown-folder response = %q, want []", raw) + } +} + +func TestIssue150LegacyReadFileContentWireShapeRemainsRawOrErrorPrefixed(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + + folderPath := filepath.Join(configDir, "issue150-legacy-read") + if errMsg := addFolderForTesting("issue150-legacy-read", "Legacy Read", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + fixtures := map[string]string{ + "empty.md": "", + "error-prefix.md": "error:legitimate note content", + "note.md": "plain note content\n", + } + for name, content := range fixtures { + if err := os.WriteFile(filepath.Join(folderPath, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + if got := ReadFileContent("issue150-legacy-read", name); got != content { + t.Errorf("legacy content for %s = %q, want exact raw bytes %q", name, got, content) + } + } + + for _, raw := range []string{ + ReadFileContent("issue150-legacy-read", "missing-redaction-probe.md"), + ReadFileContent("issue150-legacy-read", "../outside-redaction-probe.md"), + ReadFileContent("issue150-unknown-legacy-read", "note.md"), + } { + if !strings.HasPrefix(raw, "error:") { + t.Errorf("legacy read failure = %q, want error: prefix", raw) + } + for _, privateDetail := range []string{"missing-redaction-probe", "outside-redaction-probe", folderPath} { + if strings.Contains(raw, privateDetail) { + t.Errorf("legacy read failure leaked private detail %q: %q", privateDetail, raw) + } + } + } +} + +func TestIssue150ConflictInspectionV2DistinguishesCompleteEmptyListAndUnavailable(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + + folderID := "issue150-v2-inspection" + folderPath := filepath.Join(configDir, folderID) + if errMsg := addFolderForTesting(folderID, "V2 Inspection", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + + empty := issue150DecodeConflictInspectionV2(t, GetConflictFilesInspectionJSONV2(folderID)) + if empty.Version != 2 || !empty.Complete || empty.Error != "" || empty.Conflicts == nil || len(empty.Conflicts) != 0 { + t.Fatalf("complete empty inspection = %+v, want versioned verified empty", empty) + } + + conflictName := "note.sync-conflict-20260830-120000-ABC1234.md" + if err := os.WriteFile(filepath.Join(folderPath, conflictName), []byte("other bytes"), 0o600); err != nil { + t.Fatalf("write conflict fixture: %v", err) + } + complete := issue150DecodeConflictInspectionV2(t, GetConflictFilesInspectionJSONV2(folderID)) + if complete.Version != 2 || !complete.Complete || complete.Error != "" || len(complete.Conflicts) != 1 { + t.Fatalf("complete conflict inspection = %+v, want exact complete list", complete) + } + if complete.Conflicts[0].ConflictPath != conflictName { + t.Fatalf("conflict path = %q, want %q", complete.Conflicts[0].ConflictPath, conflictName) + } + + if err := os.RemoveAll(folderPath); err != nil { + t.Fatalf("remove inspection fixture: %v", err) + } + missing := issue150DecodeConflictInspectionV2(t, GetConflictFilesInspectionJSONV2(folderID)) + if missing.Version != 2 || missing.Complete || missing.Error != conflictInspectionUnavailableError || missing.Conflicts == nil || len(missing.Conflicts) != 0 { + t.Fatalf("missing-path inspection = %+v, want explicit unavailable", missing) + } + + for _, raw := range []string{ + GetConflictFilesInspectionJSONV2("issue150-v2-unknown"), + } { + unavailable := issue150DecodeConflictInspectionV2(t, raw) + if unavailable.Version != 2 || unavailable.Complete || unavailable.Error != conflictInspectionUnavailableError || unavailable.Conflicts == nil || len(unavailable.Conflicts) != 0 { + t.Fatalf("unknown inspection = %+v, want explicit unavailable", unavailable) + } + if strings.Contains(raw, "issue150-") || strings.Contains(raw, folderPath) { + t.Fatalf("unavailable V2 inspection leaked private detail: %q", raw) + } + } +} + +func TestIssue150ConflictInspectionV2KeepsPreLimitAndRejectsPostLimitEntries(t *testing.T) { + root := t.TempDir() + beforeName := "00-before.sync-conflict-20260830-120000-ABC1234.md" + afterName := "02-after.sync-conflict-20260830-120001-XYZ9876.md" + for _, name := range []string{beforeName, "01-ordinary.md", afterName} { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil { + t.Fatalf("write bounded inspection fixture %q: %v", name, err) + } + } + + result := inspectConflictFiles(root, conflictInspectionLimits{ + maxVisitedEntries: 2, + maxCollectedConflicts: 10, + }, filepath.WalkDir) + if result.Version != 2 || result.Complete || result.Error != "" { + t.Fatalf("visit-limited inspection = %+v, want versioned partial result", result) + } + if len(result.Conflicts) != 1 || result.Conflicts[0].ConflictPath != beforeName { + t.Fatalf("partial conflicts = %+v, want only pre-limit %q", result.Conflicts, beforeName) + } + for _, conflict := range result.Conflicts { + if conflict.ConflictPath == afterName { + t.Fatalf("post-limit conflict was included: %+v", conflict) + } + } +} + +func TestIssue150ConflictInspectionV2UsesIndependentCollectionBound(t *testing.T) { + root := t.TempDir() + firstName := "00-first.sync-conflict-20260830-120000-ABC1234.md" + secondName := "01-second.sync-conflict-20260830-120001-XYZ9876.md" + for _, name := range []string{firstName, secondName} { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil { + t.Fatalf("write collection-bound fixture %q: %v", name, err) + } + } + + result := inspectConflictFiles(root, conflictInspectionLimits{ + maxVisitedEntries: 10, + maxCollectedConflicts: 1, + }, filepath.WalkDir) + if result.Version != 2 || result.Complete || result.Error != "" { + t.Fatalf("collection-limited inspection = %+v, want versioned partial result", result) + } + if len(result.Conflicts) != 1 || result.Conflicts[0].ConflictPath != firstName { + t.Fatalf("collection-limited conflicts = %+v, want only %q", result.Conflicts, firstName) + } +} + +func TestIssue150ConflictInspectionV2ClassifiesPreEntryWalkFailureAsUnavailable(t *testing.T) { + root := t.TempDir() + walk := func(root string, visit fs.WalkDirFunc) error { + info, err := os.Stat(root) + if err != nil { + t.Fatalf("stat synthetic inspection root: %v", err) + } + if err := visit(root, fs.FileInfoToDirEntry(info), nil); err != nil { + return err + } + return errors.New("synthetic pre-entry walk failure") + } + + result := inspectConflictFiles(root, conflictInspectionLimits{ + maxVisitedEntries: 10, + maxCollectedConflicts: 10, + }, walk) + if result.Version != 2 || result.Complete || result.Error != conflictInspectionUnavailableError || result.Conflicts == nil || len(result.Conflicts) != 0 { + t.Fatalf("pre-entry walk failure = %+v, want explicit unavailable", result) + } +} + +func TestIssue150ReadFileContentV2DistinguishesEmptyErrorPrefixedAndUnavailable(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + + folderID := "issue150-v2-read" + folderPath := filepath.Join(configDir, folderID) + if errMsg := addFolderForTesting(folderID, "V2 Read", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + fixtures := map[string]string{ + "empty.md": "", + "error-prefix.md": "error:legitimate note content", + } + for name, content := range fixtures { + if err := os.WriteFile(filepath.Join(folderPath, name), []byte(content), 0o600); err != nil { + t.Fatalf("write V2 read fixture %q: %v", name, err) + } + got := issue150DecodeReadFileContentV2(t, ReadFileContentJSONV2(folderID, name)) + if got.Version != 2 || got.Content == nil || *got.Content != content || got.Error != "" { + t.Fatalf("V2 read for %q = %+v, want exact content", name, got) + } + } + + for _, raw := range []string{ + ReadFileContentJSONV2(folderID, "missing-redaction-probe.md"), + ReadFileContentJSONV2(folderID, "../outside-redaction-probe.md"), + ReadFileContentJSONV2("issue150-v2-unknown", "missing-redaction-probe.md"), + } { + got := issue150DecodeReadFileContentV2(t, raw) + if got.Version != 2 || got.Content != nil || got.Error != conflictInspectionUnavailableError { + t.Fatalf("V2 unavailable read = %+v, want fixed unavailable", got) + } + for _, privateDetail := range []string{"missing-redaction-probe", "outside-redaction-probe", folderPath} { + if strings.Contains(raw, privateDetail) { + t.Fatalf("V2 unavailable read leaked private detail %q: %q", privateDetail, raw) + } + } } } @@ -362,21 +618,21 @@ func TestReadFileContent(t *testing.T) { return got } - got := decode(ReadFileContent("readtest", "test.md")) + got := decode(ReadFileContentJSONV2("readtest", "test.md")) if got.Content == nil || *got.Content != content || got.Error != "" { t.Errorf("ReadFileContent = %+v, want exact content", got) } // Inspection failures return only the stable path-free code. - if got := decode(ReadFileContent("readtest", "nope.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + if got := decode(ReadFileContentJSONV2("readtest", "nope.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { t.Errorf("nonexistent file = %+v, want unavailable", got) } - if got := decode(ReadFileContent("readtest", "../../etc/passwd")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + if got := decode(ReadFileContentJSONV2("readtest", "../../etc/passwd")); got.Content != nil || got.Error != conflictInspectionUnavailableError { t.Errorf("path traversal = %+v, want unavailable", got) } - if got := decode(ReadFileContent("nonexistent", "test.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { + if got := decode(ReadFileContentJSONV2("nonexistent", "test.md")); got.Content != nil || got.Error != conflictInspectionUnavailableError { t.Errorf("nonexistent folder = %+v, want unavailable", got) } } @@ -397,16 +653,17 @@ func TestIssue150ConflictInspectionFailureIsNotAnEmptySuccess(t *testing.T) { t.Fatalf("remove isolated fixture folder: %v", err) } - if got := GetConflictFilesJSON("issue150-inspection-unavailable"); got != conflictInspectionUnavailableError { - t.Fatalf("missing-folder inspection = %q, want fixed unavailable code", got) - } - if got := GetConflictFilesJSON("issue150-unknown-folder"); got != conflictInspectionUnavailableError { - t.Fatalf("unknown-folder inspection = %q, want fixed unavailable code", got) + assertUnavailable := func(label, raw string) { + t.Helper() + got := issue150DecodeConflictInspectionV2(t, raw) + if got.Complete || got.Error != conflictInspectionUnavailableError || len(got.Conflicts) != 0 { + t.Fatalf("%s inspection = %+v, want fixed unavailable", label, got) + } } + assertUnavailable("missing-folder", GetConflictFilesInspectionJSONV2("issue150-inspection-unavailable")) + assertUnavailable("unknown-folder", GetConflictFilesInspectionJSONV2("issue150-unknown-folder")) StopSyncthing() - if got := GetConflictFilesJSON("issue150-inspection-unavailable"); got != conflictInspectionUnavailableError { - t.Fatalf("stopped-engine inspection = %q, want fixed unavailable code", got) - } + assertUnavailable("stopped-engine", GetConflictFilesInspectionJSONV2("issue150-inspection-unavailable")) } func TestIssue150ConflictInspectionDistinguishesEmptyContentAndUnavailableWithoutDetails(t *testing.T) { @@ -443,16 +700,16 @@ func TestIssue150ConflictInspectionDistinguishesEmptyContentAndUnavailableWithou if err := os.WriteFile(filepath.Join(folderPath, name), []byte(content), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } - got := decode(ReadFileContent("issue150-content-inspection", name)) + got := decode(ReadFileContentJSONV2("issue150-content-inspection", name)) if got.Content == nil || *got.Content != content || got.Error != "" { t.Fatalf("successful inspection for %q = %+v, want exact content", name, got) } } for _, raw := range []string{ - ReadFileContent("issue150-content-inspection", "missing-redaction-probe.md"), - ReadFileContent("issue150-content-inspection", "../outside-redaction-probe.md"), - ReadFileContent("issue150-unknown-folder", "missing-redaction-probe.md"), + ReadFileContentJSONV2("issue150-content-inspection", "missing-redaction-probe.md"), + ReadFileContentJSONV2("issue150-content-inspection", "../outside-redaction-probe.md"), + ReadFileContentJSONV2("issue150-unknown-folder", "missing-redaction-probe.md"), } { got := decode(raw) if got.Content != nil || got.Error != conflictInspectionUnavailableError { diff --git a/go/bridge/folderscan.go b/go/bridge/folderscan.go index ab40969..dc4a0f6 100644 --- a/go/bridge/folderscan.go +++ b/go/bridge/folderscan.go @@ -8,9 +8,12 @@ package bridge import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" + + "github.com/syncthing/syncthing/lib/config" ) // DetectedPattern describes one heavy directory (or aggregate of multiple @@ -25,6 +28,16 @@ type DetectedPattern struct { // ScanResult is the JSON envelope returned by ScanFolderForKnownPatterns. type ScanResult struct { Detected []DetectedPattern `json:"detected"` + Complete bool `json:"complete"` + Error string `json:"error,omitempty"` +} + +// The scanner never returns filesystem, folder, or driver details. +const knownPatternScanUnavailableError = "vaultsync-filter-scan-unavailable" + +type knownPatternScanEnvironment struct { + folderConfigs func() map[string]config.FolderConfiguration + inspectFolder func(string) ([]DetectedPattern, error) } var heavyDirCandidates = []struct { @@ -44,15 +57,55 @@ var heavyDirCandidates = []struct { // (the "vault subdir" pattern). Matches in multiple locations are summed // into a single entry per pattern (e.g. ".git in 3 vaults — 127 MB total"). // -// Returns {"detected":[]} for unknown folders, missing paths, or empty vaults. +// The running-engine check and SendOnly folder/path snapshot complete before +// the first filesystem access. The synchronous filesystem work then runs +// without holding the lifecycle lock; an already-started walk is not +// cancellable, while a later scan must obtain fresh authorization. func ScanFolderForKnownPatterns(folderID string) string { - folders := getFolderConfigs() + result := scanFolderForKnownPatterns(folderID, knownPatternScanEnvironment{ + folderConfigs: getFolderConfigs, + inspectFolder: inspectKnownPatterns, + }) + return marshalKnownPatternScanResult(result) +} + +func unavailableKnownPatternScanResult() ScanResult { + return ScanResult{ + Detected: []DetectedPattern{}, + Complete: false, + Error: knownPatternScanUnavailableError, + } +} + +func scanFolderForKnownPatterns(folderID string, env knownPatternScanEnvironment) ScanResult { + if env.folderConfigs == nil || env.inspectFolder == nil { + return unavailableKnownPatternScanResult() + } + folders := env.folderConfigs() if folders == nil { - return `{"detected":[]}` + return unavailableKnownPatternScanResult() } folder, ok := folders[folderID] - if !ok { - return `{"detected":[]}` + if !ok || folder.Type != config.FolderTypeSendOnly { + return unavailableKnownPatternScanResult() + } + detected, err := env.inspectFolder(folder.Path) + if err != nil { + return unavailableKnownPatternScanResult() + } + if detected == nil { + detected = []DetectedPattern{} + } + return ScanResult{Detected: detected, Complete: true} +} + +func inspectKnownPatterns(root string) ([]DetectedPattern, error) { + rootInfo, err := os.Stat(root) + if err != nil { + return nil, err + } + if !rootInfo.IsDir() { + return nil, fmt.Errorf("scan root is not a directory") } type accum struct { @@ -62,14 +115,23 @@ func ScanFolderForKnownPatterns(folderID string) string { } sums := map[string]*accum{} - checkLocation := func(base string) { + checkLocation := func(base string) error { for _, c := range heavyDirCandidates { full := filepath.Join(base, c.Pattern) info, err := os.Stat(full) - if err != nil || !info.IsDir() { + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if !info.IsDir() { continue } - size, count := dirSizeAndCount(full) + size, count, err := dirSizeAndCount(full) + if err != nil { + return err + } if count == 0 { continue } @@ -80,18 +142,25 @@ func ScanFolderForKnownPatterns(folderID string) string { sums[c.Pattern] = &accum{label: c.Label, bytes: size, count: count} } } + return nil } // Top level (single-vault setups, or stray heavy folders next to the vaults). - checkLocation(folder.Path) + if err := checkLocation(root); err != nil { + return nil, err + } // One level deep — the typical "Obsidian root with vault subdirs" layout. - if entries, err := os.ReadDir(folder.Path); err == nil { - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - checkLocation(filepath.Join(folder.Path, entry.Name())) + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + if err := checkLocation(filepath.Join(root, entry.Name())); err != nil { + return nil, err } } @@ -108,19 +177,23 @@ func ScanFolderForKnownPatterns(folderID string) string { } } - data, err := json.Marshal(ScanResult{Detected: detected}) + return detected, nil +} + +func marshalKnownPatternScanResult(result ScanResult) string { + data, err := json.Marshal(result) if err != nil { - return `{"detected":[]}` + data, _ = json.Marshal(unavailableKnownPatternScanResult()) } return string(data) } -func dirSizeAndCount(root string) (int64, int) { +func dirSizeAndCount(root string) (int64, int, error) { var total int64 var count int - _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil + return err } if !info.IsDir() { total += info.Size() @@ -128,5 +201,5 @@ func dirSizeAndCount(root string) (int64, int) { } return nil }) - return total, count + return total, count, err } diff --git a/go/bridge/folderscan_test.go b/go/bridge/folderscan_test.go index 039d2fb..2277988 100644 --- a/go/bridge/folderscan_test.go +++ b/go/bridge/folderscan_test.go @@ -4,7 +4,10 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" + + "github.com/syncthing/syncthing/lib/config" ) func TestScanFolderForKnownPatternsDetectsGitDirectory(t *testing.T) { @@ -68,7 +71,7 @@ func TestScanFolderForKnownPatternsEmptyVault(t *testing.T) { } raw := ScanFolderForKnownPatterns(folderID) - if raw != `{"detected":[]}` { + if raw != `{"detected":[],"complete":true}` { t.Errorf("got %q, want empty detected list", raw) } } @@ -81,8 +84,12 @@ func TestScanFolderForKnownPatternsUnknownFolderID(t *testing.T) { defer StopSyncthing() raw := ScanFolderForKnownPatterns("does-not-exist") - if raw != `{"detected":[]}` { - t.Errorf("got %q, want empty detected list", raw) + var result ScanResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("unmarshal failed: %v (raw=%s)", err, raw) + } + if result.Complete || result.Error != knownPatternScanUnavailableError || len(result.Detected) != 0 { + t.Errorf("unknown folder result = %+v, want explicit unavailable", result) } } @@ -253,7 +260,140 @@ func TestScanFolderForKnownPatternsIgnoresEmptyDirectories(t *testing.T) { } raw := ScanFolderForKnownPatterns(folderID) - if raw != `{"detected":[]}` { + if raw != `{"detected":[],"complete":true}` { t.Errorf("expected empty list for dir with no files, got %q", raw) } } + +func TestIssue150KnownPatternScanRejectsReceiveFoldersBeforeInspection(t *testing.T) { + for _, folderType := range []config.FolderType{ + config.FolderTypeSendReceive, + config.FolderTypeReceiveOnly, + config.FolderTypeReceiveEncrypted, + } { + t.Run(folderType.String()+" (#150)", func(t *testing.T) { + configDir := testConfigDir(t) + folderPath := filepath.Join(configDir, "receive-vault") + gitPath := filepath.Join(folderPath, ".git") + if err := os.MkdirAll(gitPath, 0o700); err != nil { + t.Fatalf("create protected scan fixture: %v", err) + } + if err := os.WriteFile(filepath.Join(gitPath, "private-note-index"), []byte("private"), 0o600); err != nil { + t.Fatalf("write protected scan fixture: %v", err) + } + issue150SeedBridgeFolderBeforeStart(t, configDir, "issue150-filter-protected", folderPath, folderType) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + + var result struct { + Detected []DetectedPattern `json:"detected"` + Complete bool `json:"complete"` + Error string `json:"error"` + } + raw := ScanFolderForKnownPatterns("issue150-filter-protected") + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("decode protected scan result: %v (raw: %q)", err, raw) + } + if result.Complete || result.Error == "" || len(result.Detected) != 0 { + t.Fatalf("protected scan returned success-shaped evidence: %+v (raw: %q)", result, raw) + } + if strings.Contains(raw, folderPath) || strings.Contains(raw, "private-note-index") { + t.Fatalf("protected scan failure leaked private detail: %q", raw) + } + }) + } +} + +func TestIssue150KnownPatternScanUnavailableStatesAreExplicitAndPathFree(t *testing.T) { + StopSyncthing() + responses := []string{ + ScanFolderForKnownPatterns("issue150-stopped-filter-scan"), + } + + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + responses = append(responses, ScanFolderForKnownPatterns("issue150-unknown-filter-scan")) + + for _, raw := range responses { + var result struct { + Detected []DetectedPattern `json:"detected"` + Complete bool `json:"complete"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("decode unavailable scan: %v (raw: %q)", err, raw) + } + if result.Complete || result.Error == "" || len(result.Detected) != 0 { + t.Fatalf("unavailable scan returned success-shaped evidence: %+v (raw: %q)", result, raw) + } + if strings.Contains(raw, "issue150-") { + t.Fatalf("unavailable scan leaked caller input: %q", raw) + } + } +} + +func TestIssue150KnownPatternScanCoreStopsBeforeFilesystemForUnauthorizedFolders(t *testing.T) { + tests := []struct { + name string + folders func() map[string]config.FolderConfiguration + }{ + {name: "engine stopped (#150)", folders: func() map[string]config.FolderConfiguration { return nil }}, + {name: "unknown folder (#150)", folders: func() map[string]config.FolderConfiguration { return map[string]config.FolderConfiguration{} }}, + {name: "send receive (#150)", folders: issue150ScanFolderConfigs(config.FolderTypeSendReceive)}, + {name: "receive only (#150)", folders: issue150ScanFolderConfigs(config.FolderTypeReceiveOnly)}, + {name: "receive encrypted (#150)", folders: issue150ScanFolderConfigs(config.FolderTypeReceiveEncrypted)}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + filesystemCalls := 0 + result := scanFolderForKnownPatterns("issue150-core-filter", knownPatternScanEnvironment{ + folderConfigs: testCase.folders, + inspectFolder: func(string) ([]DetectedPattern, error) { + filesystemCalls++ + return []DetectedPattern{{Pattern: ".git"}}, nil + }, + }) + if filesystemCalls != 0 { + t.Fatalf("unauthorized scan made %d filesystem calls, want zero", filesystemCalls) + } + if result.Complete || result.Error == "" || len(result.Detected) != 0 { + t.Fatalf("unauthorized core result = %+v, want explicit unavailable", result) + } + }) + } +} + +func TestIssue150KnownPatternScanCorePreservesSendOnlyPositiveControl(t *testing.T) { + filesystemCalls := 0 + result := scanFolderForKnownPatterns("issue150-core-filter", knownPatternScanEnvironment{ + folderConfigs: issue150ScanFolderConfigs(config.FolderTypeSendOnly), + inspectFolder: func(path string) ([]DetectedPattern, error) { + filesystemCalls++ + if path != "/synthetic/issue150-sendonly" { + t.Fatalf("inspected path = %q, want configured SendOnly path", path) + } + return []DetectedPattern{{Pattern: ".git", Label: "Git repository", SizeBytes: 7, FileCount: 1}}, nil + }, + }) + if filesystemCalls != 1 { + t.Fatalf("SendOnly scan made %d filesystem calls, want exactly one", filesystemCalls) + } + if !result.Complete || result.Error != "" || len(result.Detected) != 1 || result.Detected[0].Pattern != ".git" { + t.Fatalf("SendOnly core result = %+v, want complete detected pattern", result) + } +} + +func issue150ScanFolderConfigs(folderType config.FolderType) func() map[string]config.FolderConfiguration { + return func() map[string]config.FolderConfiguration { + return map[string]config.FolderConfiguration{ + "issue150-core-filter": { + ID: "issue150-core-filter", + Path: "/synthetic/issue150-sendonly", + Type: folderType, + }, + } + } +} diff --git a/go/bridge/issue150_database_open_test.go b/go/bridge/issue150_database_open_test.go index da06773..2ae725f 100644 --- a/go/bridge/issue150_database_open_test.go +++ b/go/bridge/issue150_database_open_test.go @@ -29,14 +29,7 @@ func TestIssue150ReceiveReadOnlyBridgeOpenDatabaseRejectsPendingMigrationWithPat } }) - configDir := t.TempDir() - dataDir := filepath.Join(configDir, "data") - if err := locations.SetBaseDir(locations.ConfigBaseDir, configDir); err != nil { - t.Fatalf("set synthetic config base: %v", err) - } - if err := locations.SetBaseDir(locations.DataBaseDir, dataDir); err != nil { - t.Fatalf("set synthetic data base: %v", err) - } + configDir := testConfigDir(t) certificate, err := tlsutil.NewCertificate( locations.Get(locations.CertFile), @@ -139,14 +132,7 @@ func TestIssue150DatabaseSafetyStopPrecedesConfigUpgradeMutation(t *testing.T) { } }) - configDir := t.TempDir() - dataDir := filepath.Join(configDir, "data") - if err := locations.SetBaseDir(locations.ConfigBaseDir, configDir); err != nil { - t.Fatalf("set config base: %v", err) - } - if err := locations.SetBaseDir(locations.DataBaseDir, dataDir); err != nil { - t.Fatalf("set data base: %v", err) - } + configDir := testConfigDir(t) certificate, err := tlsutil.NewCertificate( locations.Get(locations.CertFile), diff --git a/go/bridge/syncthing_test.go b/go/bridge/syncthing_test.go index bb8110c..9773bcf 100644 --- a/go/bridge/syncthing_test.go +++ b/go/bridge/syncthing_test.go @@ -4,29 +4,90 @@ import ( "encoding/json" "os" "strings" + "sync" "testing" "time" + + "github.com/syncthing/syncthing/lib/locations" ) -// testConfigDir creates a temporary config directory that won't fail -// on cleanup if Syncthing's database files are still being flushed. +var bridgeTestEnvironmentMu sync.Mutex + +// testConfigDir serializes every test that can mutate Syncthing's process-wide +// location bases. Cleanup stops the engine, restores both bases, and only then +// removes the synthetic home. func testConfigDir(t *testing.T) string { t.Helper() + bridgeTestEnvironmentMu.Lock() + previousConfigBase := locations.GetBaseDir(locations.ConfigBaseDir) + previousDataBase := locations.GetBaseDir(locations.DataBaseDir) + dir, err := os.MkdirTemp("", "vaultsync-test-*") if err != nil { + bridgeTestEnvironmentMu.Unlock() t.Fatal(err) } + dataDir := dir + "/data" + if err := locations.SetBaseDir(locations.ConfigBaseDir, dir); err != nil { + _ = os.RemoveAll(dir) + bridgeTestEnvironmentMu.Unlock() + t.Fatalf("set synthetic config base: %v", err) + } + if err := locations.SetBaseDir(locations.DataBaseDir, dataDir); err != nil { + _ = locations.SetBaseDir(locations.ConfigBaseDir, previousConfigBase) + _ = locations.SetBaseDir(locations.DataBaseDir, previousDataBase) + _ = os.RemoveAll(dir) + bridgeTestEnvironmentMu.Unlock() + t.Fatalf("set synthetic data base: %v", err) + } + t.Cleanup(func() { StopSyncthing() + configRestoreErr := locations.SetBaseDir(locations.ConfigBaseDir, previousConfigBase) + dataRestoreErr := locations.SetBaseDir(locations.DataBaseDir, previousDataBase) // Allow Syncthing's async config flush to complete before // removing the temp directory — avoids "rename config.xml" // log noise during test teardown. time.Sleep(100 * time.Millisecond) - os.RemoveAll(dir) + removeErr := os.RemoveAll(dir) + bridgeTestEnvironmentMu.Unlock() + + if configRestoreErr != nil { + t.Errorf("restore config base: %v", configRestoreErr) + } + if dataRestoreErr != nil { + t.Errorf("restore data base: %v", dataRestoreErr) + } + if removeErr != nil { + t.Errorf("remove synthetic bridge home: %v", removeErr) + } }) return dir } +func TestIssue150BridgeTestHomeRestoresLocationBasesBeforeDeletion(t *testing.T) { + beforeConfig := locations.GetBaseDir(locations.ConfigBaseDir) + beforeData := locations.GetBaseDir(locations.DataBaseDir) + var configDir string + + t.Run("isolated lifecycle (#150)", func(t *testing.T) { + configDir = testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + }) + + if got := locations.GetBaseDir(locations.ConfigBaseDir); got != beforeConfig { + t.Errorf("ConfigBaseDir after cleanup = %q, want restored %q", got, beforeConfig) + } + if got := locations.GetBaseDir(locations.DataBaseDir); got != beforeData { + t.Errorf("DataBaseDir after cleanup = %q, want restored %q", got, beforeData) + } + if _, err := os.Stat(configDir); !os.IsNotExist(err) { + t.Errorf("synthetic bridge home still exists after cleanup: %v", err) + } +} + func TestStartStopSyncthing(t *testing.T) { configDir := testConfigDir(t) diff --git a/ios/VaultSync/Models/DetectedPattern.swift b/ios/VaultSync/Models/DetectedPattern.swift index 5cfa9c8..99d5ca3 100644 --- a/ios/VaultSync/Models/DetectedPattern.swift +++ b/ios/VaultSync/Models/DetectedPattern.swift @@ -11,6 +11,13 @@ struct DetectedPattern: Codable, Identifiable, Sendable, Hashable { var id: String { pattern } } -struct DetectedScan: Codable, Sendable { +struct DetectedScan: Decodable, Sendable { let detected: [DetectedPattern] + let complete: Bool + let error: String? +} + +enum KnownPatternScanResult: Equatable, Sendable { + case complete([DetectedPattern]) + case unavailable } diff --git a/ios/VaultSync/Services/BackgroundSyncService.swift b/ios/VaultSync/Services/BackgroundSyncService.swift index 874dfe3..4822341 100644 --- a/ios/VaultSync/Services/BackgroundSyncService.swift +++ b/ios/VaultSync/Services/BackgroundSyncService.swift @@ -1903,10 +1903,10 @@ enum BackgroundSyncService { var count = 0 for folder in folders { - let cJSON = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) - guard let cData = cJSON.data(using: .utf8), - let conflicts = try? JSONDecoder().decode([ConflictStub].self, from: cData) else { - // Suppress rather than undercount this folder's conflicts to 0. + let raw = SyncBridgeService.getConflictFilesInspectionJSONV2(folderID: folder.id) + guard case let .complete(conflicts) = SyncthingManager.decodeConflictInspectionV2(raw) else { + // Suppress rather than undercount a partial or unavailable + // folder inspection to zero. return nil } count += Set(conflicts.map(\.originalPath)).count @@ -2257,11 +2257,6 @@ enum BackgroundSyncService { let errorPath: String? } - private struct ConflictStub: Decodable { - let originalPath: String - let conflictPath: String - } - struct SilentPushProgressTracker: Sendable { struct ProgressSnapshot: Sendable { let lastEventID: Int diff --git a/ios/VaultSync/Services/FilterScanGeneration.swift b/ios/VaultSync/Services/FilterScanGeneration.swift new file mode 100644 index 0000000..a04ee84 --- /dev/null +++ b/ios/VaultSync/Services/FilterScanGeneration.swift @@ -0,0 +1,81 @@ +/// Pure generation gate for a filter scan whose synchronous bridge work may +/// continue after its Swift task is cancelled (#150). +/// +/// The detached scanner is deliberately outside this type. Callers may commit +/// its value only when `complete` returns `.commit`; stale generations never +/// change the retry state of a newer scan. +struct FilterScanTaskID: Equatable, Sendable { + let folderID: String + let safetyState: ConflictSafetyPolicy.State +} + +struct FilterScanGeneration: Sendable { + struct Token: Equatable, Sendable { + fileprivate let generation: UInt64 + fileprivate let folderID: String + } + + enum Completion: Equatable, Sendable { + case commit + case retry + case stale + } + + private var generation: UInt64 = 0 + private var scanFolderID: String? + private(set) var activeToken: Token? + private(set) var needsScan = true + + mutating func begin( + folderID: String, + safetyState: ConflictSafetyPolicy.State + ) -> Token? { + if scanFolderID != folderID { + invalidate() + scanFolderID = folderID + } + guard safetyState == .clear else { + invalidate() + return nil + } + // A newly attached task must supersede unfinished detached work. Once + // a scan commits, no active token and `needsScan == false` suppresses + // needless rescans for the same clear folder. + guard needsScan || activeToken != nil else { return nil } + + generation &+= 1 + let token = Token(generation: generation, folderID: folderID) + activeToken = token + needsScan = false + return token + } + + mutating func invalidate() { + generation &+= 1 + activeToken = nil + needsScan = true + } + + mutating func complete( + token: Token, + currentFolderID: String, + currentSafetyState: ConflictSafetyPolicy.State, + taskCancelled: Bool, + scanComplete: Bool + ) -> Completion { + guard activeToken == token else { return .stale } + guard !taskCancelled, + token.folderID == currentFolderID, + currentSafetyState == .clear, + scanComplete else { + generation &+= 1 + activeToken = nil + needsScan = true + return .retry + } + + activeToken = nil + needsScan = false + return .commit + } +} diff --git a/ios/VaultSync/Services/SyncBridgeService.swift b/ios/VaultSync/Services/SyncBridgeService.swift index 08ec9c9..4f6040d 100644 --- a/ios/VaultSync/Services/SyncBridgeService.swift +++ b/ios/VaultSync/Services/SyncBridgeService.swift @@ -243,9 +243,9 @@ struct SyncBridgeService { // MARK: - Phase 6: Conflict management - /// Get all conflict files in a folder as JSON. - static func getConflictFilesJSON(folderID: String) -> String { - BridgeGetConflictFilesJSON(folderID) + /// Get versioned complete, partial, or unavailable conflict evidence. + static func getConflictFilesInspectionJSONV2(folderID: String) -> String { + BridgeGetConflictFilesInspectionJSONV2(folderID) } enum FileInspectionResult: Equatable, Sendable { @@ -254,15 +254,18 @@ struct SyncBridgeService { } private struct FileInspectionPayload: Decodable { + let version: Int let content: String? let error: String? } - /// Decodes the bridge's unambiguous inspection envelope. Unknown, legacy, - /// contradictory, or detailed errors fail closed to one generic state. - static func decodeFileInspectionResult(_ raw: String) -> FileInspectionResult { + /// Decodes only the V2 bridge's unambiguous inspection envelope. Unknown, + /// legacy, contradictory, or detailed errors fail closed to one generic + /// state (#150). + static func decodeFileInspectionResultV2(_ raw: String) -> FileInspectionResult { guard let data = raw.data(using: .utf8), let payload = try? JSONDecoder().decode(FileInspectionPayload.self, from: data), + payload.version == 2, payload.error == nil, let content = payload.content else { return .unavailable @@ -272,7 +275,7 @@ struct SyncBridgeService { /// Read a text file within a folder without exposing bridge/path detail. static func readFileContent(folderID: String, relPath: String) -> FileInspectionResult { - decodeFileInspectionResult(BridgeReadFileContent(folderID, relPath)) + decodeFileInspectionResultV2(BridgeReadFileContentJSONV2(folderID, relPath)) } /// ABI-compatible inspection-only recovery stub. The current bridge always diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index fcdde74..f40f399 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -309,11 +309,38 @@ final class SyncthingManager { let unavailableFolderIDs: Set } - /// A verified empty JSON array may remove cached conflicts. Any missing, - /// malformed, or explicitly unavailable response preserves the last - /// reviewable copies for that active folder and records incomplete - /// evidence instead of claiming that no conflicts exist (#150). - nonisolated static func mergeConflictInspection( + enum ConflictInspectionResultV2: Sendable { + case complete([ConflictInfo]) + case partial([ConflictInfo]) + case unavailable + } + + private struct ConflictInspectionPayloadV2: Decodable { + let version: Int + let conflicts: [ConflictInfo] + let complete: Bool + let error: String? + } + + nonisolated static func decodeConflictInspectionV2( + _ raw: String + ) -> ConflictInspectionResultV2 { + guard let data = raw.data(using: .utf8), + let payload = try? JSONDecoder().decode(ConflictInspectionPayloadV2.self, from: data), + payload.version == 2, + payload.error == nil else { + return .unavailable + } + return payload.complete + ? .complete(payload.conflicts) + : .partial(payload.conflicts) + } + + /// A verified complete V2 result may replace cached conflicts. Partial or + /// unavailable evidence remains visible as an incomplete warning, and a + /// partial list is unioned with prior copies so the visit bound cannot + /// silently erase conflicts that were already reviewable (#150). + nonisolated static func mergeConflictInspectionV2( previous: [String: [ConflictInfo]], activeFolderIDs: [String], rawByFolder: [String: String] @@ -323,17 +350,31 @@ final class SyncthingManager { var unavailable: Set = [] for folderID in activeIDs.sorted() { - guard let raw = rawByFolder[folderID], - let data = raw.data(using: .utf8), - let decoded = try? JSONDecoder().decode([ConflictInfo].self, from: data) else { + let inspection = rawByFolder[folderID].map(decodeConflictInspectionV2) + ?? .unavailable + switch inspection { + case let .complete(decoded): + if !decoded.isEmpty { + conflicts[folderID] = decoded + } + + case let .partial(decoded): + var merged: [ConflictInfo] = [] + var seenPaths: Set = [] + for conflict in decoded + previous[folderID, default: []] + where seenPaths.insert(conflict.conflictPath).inserted { + merged.append(conflict) + } + if !merged.isEmpty { + conflicts[folderID] = merged + } + unavailable.insert(folderID) + + case .unavailable: if let retained = previous[folderID], !retained.isEmpty { conflicts[folderID] = retained } unavailable.insert(folderID) - continue - } - if !decoded.isEmpty { - conflicts[folderID] = decoded } } return ConflictInspectionSnapshot( @@ -368,6 +409,7 @@ final class SyncthingManager { case disconnectedPeers case pendingShares case conflicts + case conflictInspectionUnavailable case staleSync case backgroundSync } @@ -382,7 +424,7 @@ final class SyncthingManager { let deviceID: String? var id: String { - "\(kind.rawValue)|\(count)|\(folderID ?? "")|\(deviceID ?? "")" + "\(kind.rawValue)|\(folderID ?? "")|\(deviceID ?? "")" } } @@ -757,7 +799,7 @@ final class SyncthingManager { let count = conflictInspectionUnavailableFolderIDs.count issues.append( SyncIssueItem( - kind: .conflicts, + kind: .conflictInspectionUnavailable, title: L10n.tr("Conflict Inspection Unavailable"), message: L10n.tr("VaultSync cannot verify whether the conflict list is complete."), remediation: L10n.tr("Open conflicts to review any previously visible copies. No recovery action is available."), @@ -1013,7 +1055,7 @@ final class SyncthingManager { /// receive-capable folder, which is read-only in 2.0.2, so return before /// bridge, refresh, marker, scan, or persistence work (#150). func addFolder(id: String, label: String, path: String) -> String? { - "vaultsync-conflict-retention-safety-stop" + ConflictSafetyPolicy.engineStopMarker } /// Remove a folder by ID. @@ -1220,7 +1262,7 @@ final class SyncthingManager { /// offer is receive-capable, so 2.0.2 returns before bridge, folder-list, /// removed-state, sidecar, scan, or persistence work (#150). func acceptPendingFolder(folderID: String, label: String, path: String, allowNonEmpty: Bool) -> String? { - "vaultsync-conflict-retention-safety-stop" + ConflictSafetyPolicy.engineStopMarker } // MARK: - Device rename @@ -1385,7 +1427,7 @@ final class SyncthingManager { if let status = SyncBridgeService.getFolderStatus(folderID: folder.id) { statuses[folder.id] = FolderStatusInfo(payload: status) } - conflicts[folder.id] = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) + conflicts[folder.id] = SyncBridgeService.getConflictFilesInspectionJSONV2(folderID: folder.id) } return (statuses, conflicts) }.value @@ -1417,7 +1459,7 @@ final class SyncthingManager { ) folderStatuses = newStatuses - let conflictSnapshot = Self.mergeConflictInspection( + let conflictSnapshot = Self.mergeConflictInspectionV2( previous: conflictFiles, activeFolderIDs: currentFolders.map(\.id), rawByFolder: statusSnapshot.1 @@ -1529,10 +1571,10 @@ final class SyncthingManager { private func refreshConflicts() { let rawByFolder = Dictionary( uniqueKeysWithValues: folders.map { - ($0.id, SyncBridgeService.getConflictFilesJSON(folderID: $0.id)) + ($0.id, SyncBridgeService.getConflictFilesInspectionJSONV2(folderID: $0.id)) } ) - let snapshot = Self.mergeConflictInspection( + let snapshot = Self.mergeConflictInspectionV2( previous: conflictFiles, activeFolderIDs: folders.map(\.id), rawByFolder: rawByFolder @@ -2612,16 +2654,25 @@ final class SyncthingManager { return setIgnorePatterns(folderID: folderID, patterns: patterns) } + /// Decode only explicit complete evidence. Older envelopes and bridge + /// refusal states cannot impersonate a verified empty scan (#150). + nonisolated static func decodeKnownPatternScan(_ raw: String) -> KnownPatternScanResult { + guard let data = raw.data(using: .utf8), + let decoded = try? JSONDecoder().decode(DetectedScan.self, from: data), + decoded.complete, + decoded.error?.isEmpty != false else { + return .unavailable + } + return .complete(decoded.detected) + } + /// Run the Go-side scanner for known heavy directories. /// `nonisolated static` so views can dispatch it on a detached Task without /// blocking the main actor. - nonisolated static func scanFolderForKnownPatterns(folderID: String) -> [DetectedPattern] { - let raw = SyncBridgeService.scanFolderForKnownPatterns(folderID: folderID) - guard let data = raw.data(using: .utf8), - let decoded = try? JSONDecoder().decode(DetectedScan.self, from: data) else { - return [] - } - return decoded.detected + nonisolated static func scanFolderForKnownPatterns(folderID: String) -> KnownPatternScanResult { + decodeKnownPatternScan( + SyncBridgeService.scanFolderForKnownPatterns(folderID: folderID) + ) } func hasShownRecommendationSheet(folderID: String) -> Bool { diff --git a/ios/VaultSync/Views/IgnorePatternsView.swift b/ios/VaultSync/Views/IgnorePatternsView.swift index 61cc6a7..2fc26eb 100644 --- a/ios/VaultSync/Views/IgnorePatternsView.swift +++ b/ios/VaultSync/Views/IgnorePatternsView.swift @@ -3,12 +3,27 @@ import SwiftUI struct IgnorePatternsView: View { let folderID: String let syncthingManager: SyncthingManager + private let scanner: @Sendable (String) async -> KnownPatternScanResult @State private var ignoredPatterns: Set = [] @State private var detected: [DetectedPattern] = [] @State private var newPattern: String = "" @State private var alertMessage: String? - @State private var hasLoadedScan = false + @State private var scanGeneration = FilterScanGeneration() + + init( + folderID: String, + syncthingManager: SyncthingManager, + scanner: @escaping @Sendable (String) async -> KnownPatternScanResult = { capturedFolderID in + await Task.detached(priority: .utility) { + SyncthingManager.scanFolderForKnownPatterns(folderID: capturedFolderID) + }.value + } + ) { + self.folderID = folderID + self.syncthingManager = syncthingManager + self.scanner = scanner + } var body: some View { Group { @@ -33,8 +48,7 @@ struct IgnorePatternsView: View { } .navigationTitle(L10n.tr("Sync Filters")) .navigationBarTitleDisplayMode(.inline) - .task(id: safetyState) { - guard allowsChanges else { return } + .task(id: scanTaskID) { await initialLoad() } .alert(L10n.tr("Sync Filter Error"), isPresented: errorBinding) { @@ -46,6 +60,10 @@ struct IgnorePatternsView: View { syncthingManager.conflictSafetyState(folderID: folderID) } + private var scanTaskID: FilterScanTaskID { + FilterScanTaskID(folderID: folderID, safetyState: safetyState) + } + private var allowsChanges: Bool { ConflictSafetyPolicy.allowsMutation(for: safetyState) } @@ -231,15 +249,38 @@ struct IgnorePatternsView: View { // MARK: - Loading private func initialLoad() async { + guard safetyState == .clear else { + scanGeneration.invalidate() + detected.removeAll() + return + } + reloadPatterns() - if !hasLoadedScan { - hasLoadedScan = true - let id = folderID - let scanResult = await Task.detached { - SyncthingManager.scanFolderForKnownPatterns(folderID: id) - }.value - detected = scanResult + guard let token = scanGeneration.begin( + folderID: folderID, + safetyState: safetyState + ) else { return } + detected.removeAll() + + let capturedFolderID = folderID + let scanResult = await scanner(capturedFolderID) + let scanComplete: Bool + switch scanResult { + case .complete: + scanComplete = true + case .unavailable: + scanComplete = false } + let completion = scanGeneration.complete( + token: token, + currentFolderID: folderID, + currentSafetyState: safetyState, + taskCancelled: Task.isCancelled, + scanComplete: scanComplete + ) + guard completion == .commit, + case let .complete(result) = scanResult else { return } + detected = result } private func reloadPatterns() { diff --git a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift index 0bd8222..9a2c615 100644 --- a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift +++ b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift @@ -3,14 +3,29 @@ import SwiftUI struct SyncFilterRecommendationSheet: View { let folderID: String let syncthingManager: SyncthingManager + private let scanner: @Sendable (String) async -> KnownPatternScanResult @Environment(\.dismiss) private var dismiss @State private var detected: [DetectedPattern] = [] @State private var enabledPresetIDs: Set = Set(IgnorePreset.recommended.map(\.id)) @State private var enabledDetectedPatterns: Set = [] - @State private var hasScanned = false + @State private var scanGeneration = FilterScanGeneration() @State private var applyErrorMessage: String? + init( + folderID: String, + syncthingManager: SyncthingManager, + scanner: @escaping @Sendable (String) async -> KnownPatternScanResult = { capturedFolderID in + await Task.detached(priority: .utility) { + SyncthingManager.scanFolderForKnownPatterns(folderID: capturedFolderID) + }.value + } + ) { + self.folderID = folderID + self.syncthingManager = syncthingManager + self.scanner = scanner + } + var body: some View { NavigationStack { Group { @@ -75,8 +90,7 @@ struct SyncFilterRecommendationSheet: View { } } } - .task(id: safetyState) { - guard allowsChanges else { return } + .task(id: scanTaskID) { await scan() } .alert(L10n.tr("Could not save filters"), isPresented: errorBinding) { @@ -91,6 +105,10 @@ struct SyncFilterRecommendationSheet: View { syncthingManager.conflictSafetyState(folderID: folderID) } + private var scanTaskID: FilterScanTaskID { + FilterScanTaskID(folderID: folderID, safetyState: safetyState) + } + private var allowsChanges: Bool { ConflictSafetyPolicy.allowsMutation(for: safetyState) } @@ -154,12 +172,40 @@ struct SyncFilterRecommendationSheet: View { } private func scan() async { - guard !hasScanned else { return } - hasScanned = true - let id = folderID - let result = await Task.detached { - SyncthingManager.scanFolderForKnownPatterns(folderID: id) - }.value + guard safetyState == .clear else { + scanGeneration.invalidate() + detected.removeAll() + enabledDetectedPatterns.removeAll() + enabledPresetIDs = Set(IgnorePreset.recommended.map(\.id)) + return + } + guard let token = scanGeneration.begin( + folderID: folderID, + safetyState: safetyState + ) else { return } + detected.removeAll() + enabledDetectedPatterns.removeAll() + enabledPresetIDs = Set(IgnorePreset.recommended.map(\.id)) + + let capturedFolderID = folderID + let scanResult = await scanner(capturedFolderID) + let scanComplete: Bool + switch scanResult { + case .complete: + scanComplete = true + case .unavailable: + scanComplete = false + } + let completion = scanGeneration.complete( + token: token, + currentFolderID: folderID, + currentSafetyState: safetyState, + taskCancelled: Task.isCancelled, + scanComplete: scanComplete + ) + guard completion == .commit, + case let .complete(result) = scanResult else { return } + detected = result for item in result { if let preset = IgnorePreset.preset(forDetectedPattern: item.pattern) { diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index 8cc4752..d15b665 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -45,7 +45,8 @@ struct SyncIssuesView: View { private func symbol(for issue: SyncthingManager.SyncIssueItem) -> String { switch issue.kind { - case .pathCollision, .nestedFolders, .conflictRetentionSafety, .folderErrors, .conflicts, .staleSync: + case .pathCollision, .nestedFolders, .conflictRetentionSafety, .folderErrors, + .conflicts, .conflictInspectionUnavailable, .staleSync: return "exclamationmark.triangle.fill" case .backgroundSync: return "clock.badge.exclamationmark" @@ -124,21 +125,10 @@ struct SyncIssuesView: View { EmptyView() case .conflicts: - if let destination = Self.conflictDestination( - preferredFolderID: issue.folderID, - conflictFiles: syncthingManager.conflictFiles, - unavailableFolderIDs: syncthingManager.conflictInspectionUnavailableFolderIDs, - allowFallback: true - ) { - NavigationLink(L10n.tr("Review Conflicts")) { - ConflictListView( - folderID: destination, - syncthingManager: syncthingManager - ) - } - .buttonStyle(.bordered) - .controlSize(.regular) - } + conflictReviewAction(for: issue) + + case .conflictInspectionUnavailable: + conflictReviewAction(for: issue) case .staleSync: if !syncthingManager.foregroundRescanEligibleFolderIDs.isEmpty { @@ -160,6 +150,25 @@ struct SyncIssuesView: View { } } + @ViewBuilder + private func conflictReviewAction(for issue: SyncthingManager.SyncIssueItem) -> some View { + if let destination = Self.conflictDestination( + preferredFolderID: issue.folderID, + conflictFiles: syncthingManager.conflictFiles, + unavailableFolderIDs: syncthingManager.conflictInspectionUnavailableFolderIDs, + allowFallback: true + ) { + NavigationLink(L10n.tr("Review Conflicts")) { + ConflictListView( + folderID: destination, + syncthingManager: syncthingManager + ) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + } + nonisolated static func conflictDestination( preferredFolderID: String?, conflictFiles: [String: [SyncthingManager.ConflictInfo]], @@ -194,7 +203,7 @@ struct SyncIssuesView: View { anchor = "bookmark-access-expired" case .disconnectedPeers: anchor = "required-device-disconnected" - case .conflicts: + case .conflicts, .conflictInspectionUnavailable: // No conflict-resolution section exists in the troubleshooting doc, // and "Background Sync Not Working" is unrelated. The inline // review action is the complete read-only path, so don't surface a diff --git a/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift index 05f2701..0673f98 100644 --- a/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift +++ b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift @@ -198,6 +198,11 @@ struct DurableIssueFloorTests { #expect(floor([(.pendingShares, .warning), (.pathCollision, .critical)]) == .critical) } + @Test("Conflict inspection unavailable remains a durable widget warning (#150)") + func conflictInspectionUnavailableIsDurableIssue150() { + #expect(floor([(.conflictInspectionUnavailable, .warning)]) == .warning) + } + // A successful background run resolves staleness by definition, and the // completion write knows the fresh background outcome — recording either // would stick a false amber only a foreground open could clear. diff --git a/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift b/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift index 26adbc6..32d3bbd 100644 --- a/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift +++ b/ios/VaultSyncTests/ConflictRetentionSafetyIntegrationTests.swift @@ -1132,7 +1132,7 @@ struct ConflictRetentionSafetyIntegrationTests { from: "func addFolder(id:", to: "/// Remove a folder by ID." ) - #expect(add.contains(ConflictSafetyPolicy.engineStopMarker)) + #expect(add.contains("ConflictSafetyPolicy.engineStopMarker")) #expect(!add.contains("SyncBridgeService.addFolder")) let accept = try sourceSection( @@ -1140,7 +1140,7 @@ struct ConflictRetentionSafetyIntegrationTests { from: "func acceptPendingFolder(folderID:", to: "// MARK: - Device rename" ) - #expect(accept.contains(ConflictSafetyPolicy.engineStopMarker)) + #expect(accept.contains("ConflictSafetyPolicy.engineStopMarker")) #expect(!accept.contains("SyncBridgeService.acceptPendingFolder")) let diagnostics = try productSource("VaultSync/Views/ControlledDiagnosticsView.swift") @@ -1163,30 +1163,37 @@ struct ConflictRetentionSafetyIntegrationTests { @Test("Conflict inspection distinguishes content, empty files, and unavailable reads (#150)") func conflictFileInspectionPayloadIsUnambiguousIssue150() { - #expect(SyncBridgeService.decodeFileInspectionResult( - #"{"content":"error:legitimate note text"}"# + #expect(SyncBridgeService.decodeFileInspectionResultV2( + #"{"version":2,"content":"error:legitimate note text"}"# ) == .content("error:legitimate note text")) - #expect(SyncBridgeService.decodeFileInspectionResult( - #"{"content":""}"# + #expect(SyncBridgeService.decodeFileInspectionResultV2( + #"{"version":2,"content":""}"# ) == .content("")) - #expect(SyncBridgeService.decodeFileInspectionResult( - #"{"error":"vaultsync-conflict-inspection-unavailable"}"# + #expect(SyncBridgeService.decodeFileInspectionResultV2( + #"{"version":2,"error":"vaultsync-conflict-inspection-unavailable"}"# ) == .unavailable) - #expect(SyncBridgeService.decodeFileInspectionResult( + #expect(SyncBridgeService.decodeFileInspectionResultV2( + #"{"content":"legacy-envelope-without-version"}"# + ) == .unavailable) + #expect(SyncBridgeService.decodeFileInspectionResultV2( "error:redaction-probe-note.md" ) == .unavailable) } @Test("Unavailable conflict inspection preserves prior review copies without claiming empty (#150)") func conflictInspectionCacheIsFailClosedIssue150() { + let conflictsJSON = String( + data: try! JSONEncoder().encode([conflict]), + encoding: .utf8 + )! let previous = ["a": [conflict], "removed": [conflict]] - let snapshot = SyncthingManager.mergeConflictInspection( + let snapshot = SyncthingManager.mergeConflictInspectionV2( previous: previous, activeFolderIDs: ["a", "b", "c"], rawByFolder: [ - "a": "vaultsync-conflict-inspection-unavailable", - "b": "[]", - "c": String(data: try! JSONEncoder().encode([conflict]), encoding: .utf8)!, + "a": #"{"version":2,"conflicts":[],"complete":false,"error":"vaultsync-conflict-inspection-unavailable"}"#, + "b": #"{"version":2,"conflicts":[],"complete":true}"#, + "c": #"{"version":2,"conflicts":\#(conflictsJSON),"complete":true}"#, ] ) @@ -1197,6 +1204,360 @@ struct ConflictRetentionSafetyIntegrationTests { #expect(snapshot.unavailableFolderIDs == ["a"]) } + @Test("Partial conflict inspection retains prior copies and marks the list incomplete (#150)") + func partialConflictInspectionRetainsPriorCopiesIssue150() { + let newlyObserved = SyncthingManager.ConflictInfo( + originalPath: "later-fixture-note.md", + conflictPath: "later-fixture-note.sync-conflict-20000101-000000-FIXTURE.md", + conflictDate: "20000101-000000", + deviceShortID: "FIXTURE" + ) + let partialJSON = String( + data: try! JSONEncoder().encode([newlyObserved]), + encoding: .utf8 + )! + let snapshot = SyncthingManager.mergeConflictInspectionV2( + previous: ["partial": [conflict]], + activeFolderIDs: ["partial"], + rawByFolder: [ + "partial": #"{"version":2,"conflicts":\#(partialJSON),"complete":false}"#, + ] + ) + + #expect(Set(snapshot.conflicts["partial", default: []].map(\.conflictPath)) == [ + conflict.conflictPath, + newlyObserved.conflictPath, + ]) + #expect(snapshot.unavailableFolderIDs == ["partial"]) + } + + @Test("Conflict inspection V2 rejects legacy future and contradictory envelopes (#150)") + func conflictInspectionV2DecoderFailsClosedIssue150() { + for raw in [ + "[]", + #"{"version":1,"conflicts":[],"complete":true}"#, + #"{"version":3,"conflicts":[],"complete":true}"#, + #"{"version":2,"conflicts":[],"complete":true,"error":"contradictory"}"#, + #"{"version":2,"conflicts":[],"complete":false,"error":"vaultsync-conflict-inspection-unavailable"}"#, + "not-json", + ] { + if case .unavailable = SyncthingManager.decodeConflictInspectionV2(raw) { + // Expected fail-closed result. + } else { + Issue.record("unexpectedly accepted conflict inspection: \(raw)") + } + } + } + + @Test("Current Swift uses only additive V2 inspection entry points (#150)") + func currentSwiftUsesOnlyV2InspectionEntryPointsIssue150() throws { + let bridge = try productSource("VaultSync/Services/SyncBridgeService.swift") + #expect(bridge.contains("BridgeGetConflictFilesInspectionJSONV2(")) + #expect(bridge.contains("BridgeReadFileContentJSONV2(")) + #expect(!bridge.contains("BridgeGetConflictFilesJSON(")) + #expect(!bridge.contains("BridgeReadFileContent(")) + + let manager = try productSource("VaultSync/Services/SyncthingManager.swift") + #expect(manager.contains("getConflictFilesInspectionJSONV2(folderID:")) + #expect(manager.contains("mergeConflictInspectionV2(")) + #expect(!manager.contains("getConflictFilesJSON(folderID:")) + + let background = try productSource("VaultSync/Services/BackgroundSyncService.swift") + #expect(background.contains("getConflictFilesInspectionJSONV2(folderID:")) + #expect(background.contains("decodeConflictInspectionV2(")) + #expect(!background.contains("getConflictFilesJSON(folderID:")) + } + + @Test("Retained conflict and unavailable inspection keep unique stable issue identities (#150)") + @MainActor + func retainedConflictAndUnavailableInspectionHaveUniqueStableIDsIssue150() { + let manager = makeManager(folderType: "sendonly") + manager._testSetConflictFiles(["fixture-folder-a": [conflict]]) + manager._testSetConflictInspectionUnavailableFolderIDs(["fixture-folder-a"]) + + let conflictIssues = manager.unresolvedIssues.filter { + $0.title == L10n.tr("1 Conflict Available for Review") + || $0.title == L10n.tr("Conflict Inspection Unavailable") + } + + #expect(conflictIssues.count == 2) + #expect(Set(conflictIssues.map(\.kind)) == [.conflicts, .conflictInspectionUnavailable]) + #expect(Set(conflictIssues.map(\.id)).count == conflictIssues.count) + #expect(conflictIssues.allSatisfy { + SyncIssuesView.conflictDestination( + preferredFolderID: $0.folderID, + conflictFiles: manager.conflictFiles, + unavailableFolderIDs: manager.conflictInspectionUnavailableFolderIDs, + allowFallback: true + ) == "fixture-folder-a" + }) + #expect(SyncthingManager.durableIssueFloor( + issues: conflictIssues.map { ($0.kind, $0.severity) }, + hasUnreachableFolders: false + ) == .warning) + + let one = SyncthingManager.SyncIssueItem( + kind: .conflicts, + title: "Fixture", + message: "Fixture", + remediation: "Fixture", + severity: .warning, + count: 1, + folderID: "fixture-folder-a", + deviceID: nil + ) + let two = SyncthingManager.SyncIssueItem( + kind: .conflicts, + title: "Changed fixture title", + message: "Changed fixture message", + remediation: "Changed fixture remediation", + severity: .warning, + count: 2, + folderID: "fixture-folder-a", + deviceID: nil + ) + #expect(one.id == two.id) + } + + @Test("Filter scans require generation folder cancellation and fresh safety guards (#150)") + func filterScansExposeEveryStaleResultGuardIssue150() throws { + let ignorePatterns = try productSource("VaultSync/Views/IgnorePatternsView.swift") + let ignoreLoad = try sourceSection( + ignorePatterns, + from: "private func initialLoad() async", + to: "private func reloadPatterns()" + ) + let recommendation = try productSource("VaultSync/Views/SyncFilterRecommendationSheet.swift") + let recommendationScan = try sourceSection( + recommendation, + from: "private func scan() async", + to: "/// Delegate the deselect-aware" + ) + + for source in [ignoreLoad, recommendationScan] { + #expect(source.contains("scanGeneration")) + #expect(source.contains("Task.isCancelled")) + #expect(source.contains("capturedFolderID")) + #expect(source.contains("safetyState == .clear")) + #expect(source.contains("await scanner(capturedFolderID)")) + #expect(source.contains("case .complete")) + } + #expect(ignorePatterns.contains(".task(id: scanTaskID)")) + #expect(recommendation.contains(".task(id: scanTaskID)")) + #expect(ignorePatterns.contains("detected.removeAll()")) + #expect(recommendation.contains("enabledDetectedPatterns.removeAll()")) + } + + @Test("Filter scan task identity includes folder and fresh safety state (#150)") + func filterScanTaskIdentityIncludesFolderAndSafetyIssue150() { + let clearA = FilterScanTaskID(folderID: "fixture-folder-a", safetyState: .clear) + let clearB = FilterScanTaskID(folderID: "fixture-folder-b", safetyState: .clear) + let stoppedA = FilterScanTaskID(folderID: "fixture-folder-a", safetyState: .stopped) + + #expect(clearA != clearB) + #expect(clearA != stoppedA) + + var scanGeneration = FilterScanGeneration() + let scanA = scanGeneration.begin(folderID: clearA.folderID, safetyState: clearA.safetyState)! + #expect(scanGeneration.complete( + token: scanA, + currentFolderID: clearA.folderID, + currentSafetyState: clearA.safetyState, + taskCancelled: false, + scanComplete: true + ) == .commit) + #expect(scanGeneration.begin(folderID: clearB.folderID, safetyState: clearB.safetyState) != nil) + } + + @Test("Reappearing clear filter task supersedes an unfinished scan (#150)") + func reappearingClearFilterTaskSupersedesUnfinishedScanIssue150() { + var scanGeneration = FilterScanGeneration() + let scanA = scanGeneration.begin( + folderID: "fixture-folder-a", + safetyState: .clear + )! + + let scanB = scanGeneration.begin( + folderID: "fixture-folder-a", + safetyState: .clear + ) + #expect(scanB != nil) + guard let scanB else { return } + + #expect(scanGeneration.complete( + token: scanB, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: false, + scanComplete: true + ) == .commit) + #expect(scanGeneration.complete( + token: scanA, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: true, + scanComplete: true + ) == .stale) + #expect(!scanGeneration.needsScan) + #expect(scanGeneration.begin( + folderID: "fixture-folder-a", + safetyState: .clear + ) == nil) + } + + @Test("Known-pattern scan decoding requires explicit complete evidence (#150)") + func filterScanDecoderIsExplicitlyCompleteIssue150() { + #expect(SyncthingManager.decodeKnownPatternScan( + #"{"detected":[],"complete":true}"# + ) == .complete([])) + #expect(SyncthingManager.decodeKnownPatternScan( + #"{"detected":[]}"# + ) == .unavailable) + #expect(SyncthingManager.decodeKnownPatternScan( + #"{"detected":[],"complete":false,"error":"vaultsync-filter-scan-unavailable"}"# + ) == .unavailable) + #expect(SyncthingManager.decodeKnownPatternScan( + #"{"detected":[],"complete":true,"error":"contradictory"}"# + ) == .unavailable) + #expect(SyncthingManager.decodeKnownPatternScan("not-json") == .unavailable) + } + + @Test("Non-clear filter safety never starts an injected scanner (#150)") + func nonClearFilterSafetyStartsNoScannerIssue150() async { + var scanGeneration = FilterScanGeneration() + var scannerCallCount = 0 + + if scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .stopped) != nil { + scannerCallCount += 1 + } + + #expect(scannerCallCount == 0) + #expect(scanGeneration.needsScan) + #expect(scanGeneration.activeToken == nil) + } + + @Test("Clear to stopped rejects a late filter result and retries safely (#150)") + func stoppedFilterSafetyRejectsLateResultIssue150() { + var scanGeneration = FilterScanGeneration() + let scanA = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear) + #expect(scanA != nil) + + scanGeneration.invalidate() + var detected = ["stale-before-stop"] + var automaticSelections: Set = ["stale-before-stop"] + detected.removeAll() + automaticSelections.removeAll() + + let completion = scanGeneration.complete( + token: scanA!, + currentFolderID: "fixture-folder-a", + currentSafetyState: .stopped, + taskCancelled: true, + scanComplete: true + ) + if completion == .commit { + detected = ["scan-a"] + automaticSelections = ["scan-a"] + } + + #expect(completion == .stale) + #expect(detected.isEmpty) + #expect(automaticSelections.isEmpty) + #expect(scanGeneration.needsScan) + } + + @Test("A newer clear filter scan wins without stale selection or retry changes (#150)") + func newerFilterScanWinsAndOlderScanStaysInertIssue150() { + var scanGeneration = FilterScanGeneration() + let scanA = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear)! + scanGeneration.invalidate() + let scanB = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear)! + + var detected: [String] = [] + var automaticSelections: Set = [] + let completionB = scanGeneration.complete( + token: scanB, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: false, + scanComplete: true + ) + if completionB == .commit { + detected = ["scan-b"] + automaticSelections = ["scan-b"] + } + #expect(!scanGeneration.needsScan) + + let completionA = scanGeneration.complete( + token: scanA, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: false, + scanComplete: true + ) + if completionA == .commit { + detected = ["scan-a"] + automaticSelections = ["scan-a"] + } + + #expect(completionB == .commit) + #expect(completionA == .stale) + #expect(detected == ["scan-b"]) + #expect(automaticSelections == ["scan-b"]) + #expect(!scanGeneration.needsScan) + } + + @Test("Cancelled current filter scan retries without authorizing its result (#150)") + func cancelledCurrentFilterScanRetriesIssue150() { + var scanGeneration = FilterScanGeneration() + let cancelled = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear)! + let completion = scanGeneration.complete( + token: cancelled, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: true, + scanComplete: true + ) + + #expect(completion == .retry) + #expect(scanGeneration.needsScan) + #expect(scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear) != nil) + } + + @Test("SendOnly clear safety still commits an injected filter scan (#150)") + func sendOnlyFilterScanPositiveControlIssue150() { + var scanGeneration = FilterScanGeneration() + let state = ConflictSafetyPolicy.runtimeState(forFolderType: "sendonly") + let token = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: state)! + let completion = scanGeneration.complete( + token: token, + currentFolderID: "fixture-folder-a", + currentSafetyState: state, + taskCancelled: false, + scanComplete: true + ) + + #expect(state == .clear) + #expect(completion == .commit) + #expect(!scanGeneration.needsScan) + } + + @Test("Unavailable injected filter scan cannot commit an empty result (#150)") + func unavailableFilterScanCannotCommitEmptyIssue150() { + var scanGeneration = FilterScanGeneration() + let token = scanGeneration.begin(folderID: "fixture-folder-a", safetyState: .clear)! + let completion = scanGeneration.complete( + token: token, + currentFolderID: "fixture-folder-a", + currentSafetyState: .clear, + taskCancelled: false, + scanComplete: false + ) + + #expect(completion == .retry) + #expect(scanGeneration.needsScan) + } + @Test("Default foreground rescans select only SendOnly folders as one batch (#150)") func defaultForegroundRescanTargetsAreSendOnlyIssue150() throws { let folders = [ @@ -1316,7 +1677,24 @@ struct ConflictRetentionSafetyIntegrationTests { receiveSafetyState: { .stopped }, preflight: { _, _, _ in counter.record("preflight") - fatalError("preflight must remain unreachable") + Issue.record("preflight must remain unreachable") + return DiagnosticsUploadPreflight( + folderID: "", + folderPath: "", + peerID: "", + engineGeneration: 0, + engineRunning: false, + pathsSettled: false, + folderMode: "", + folderPaused: true, + folderHealthy: false, + designatedPeerIDs: [], + peerConnected: false, + peerPaused: true, + pathOverlap: true, + namespacePathAllowed: false, + operationSlotEmpty: false + ) }, rescan: { counter.record("rescan") @@ -1358,4 +1736,3 @@ struct ConflictRetentionSafetyIntegrationTests { return String(source[start.. Cloud Relay accelerates **server → iPhone** only. For **iPhone → server**, open VaultSync (see **Product scope** below). +> [!IMPORTANT] +> VaultSync 2.0.2 can still receive and report this helper's wake-up, but its +> temporary #150 containment boundary prevents regular receive-capable vaults +> from pulling server changes or scanning and uploading new iPhone edits. +> Existing Send Only folders remain eligible for upload work. A recent wake-up +> is therefore not evidence that a frozen vault synchronized. + --- ## ⚡ One-step setup