Repository navigation
bug(syncengine): additional-root pulls and deletions bypass pre-replacement safety snapshots #82
Description
Summary
Ordinary P2P synchronization of an additional named save location can overwrite or delete local files without first establishing a usable pre-replacement snapshot. The primary save location already refuses to replace at-risk files when its safety capture fails; the additional-location path does not apply the same guard.
This is a missing protection boundary for existing local data, not a request to block harmless downloads of new files or all legitimate peer updates.
Code path analysis and current status
- Source reviewed:
dev-publicat0b8d8bfec803, 16 October 2026. - Primary-root safety boundary: the primary-root path computes
filesAtRisk(localManifest, decision). If existing local files would be replaced or removed, it callsCreateBeforeReplacingand returns before entering the local-write section when capture fails. - Additional-root apply path:
syncOneRootcomputes its decision and conflict state, then enters the mutation sequence:Writing→applyLocalDeletions→propagateDeletions→createPulledDirsIn→pullFiles. - Missing admission: there is no corresponding
filesAtRiskcalculation orCreateBeforeReplacingcheck between the additional-root decision and that mutation sequence. - Post-apply state: after application, the function proceeds into its normal lineage/agreed-hash update path. The bug(syncengine): additional-root pulls and deletions bypass pre-replacement safety snapshots #82 tests directly assert the byte-preservation failure; they do not independently assert every possible metadata result.
- Related work: the open snapshot/watcher PR #54 addresses incomplete automatic captures and watcher behaviour, but does not add this admission boundary to the additional-root apply path.
Both reproduced cases reach the same unguarded boundary: replacement reaches pullFiles, while deletion reaches applyLocalDeletions, without first proving that the endangered local version is recoverable.
Reproduction — two synthetic loopback peers
The issue was reproduced with two isolated Go testutil.TestDaemon peers, temporary file trees and loopback HTTP (no real game files or external services).
- Track one synthetic game on peers A and B, each with a named additional location
config. Pair the peers and letsettings.ini = BASEconverge, establishing shared lineage. - Disable automatic syncing for the test and wait for prior activity to settle.
- Change only A's
settings.initoREMOTE NEW. B still holdsBASE. - On B, configure
BackupsDirto refer to an existing regular file rather than a directory; creating a new local safety ZIP must now fail deterministically. - Trigger B's synchronization with A. Inspect B's bytes, the result, and locally available safety snapshots.
- Repeat from a fresh paired fixture for a deletion: both peers start with
critical.ini = BASE; A deliberately deletes it; B still has it; B's snapshot destination is blocked before sync.
| Case | Required safety behaviour | Observed file outcome | Sync API result |
|---|---|---|---|
| Existing B file would be replaced | Refuse/defer the destructive update if required safety capture fails; preserve B's prior bytes | B's settings.ini became REMOTE NEW |
status="in_sync", direction="none" |
| Existing B file would be deleted | Refuse/defer the deletion if required safety capture fails; preserve B's prior bytes | B's critical.ini was removed |
status="in_sync", direction="none" |
The remote update and deletion are ordinary valid sync decisions when adequate recovery protection exists. The top-level sync call returned status="in_sync", direction="none" in both reproductions. These fields may summarize the primary root, but no location-specific failure was surfaced for the additional-root mutation. The defect is applying the additional-root changes without the protection required for the corresponding primary-root operation.
Evidence and limits
- Test baseline:
dev-public@0b8d8bfec803, 16 October 2026. Synthetic paired-daemon regressions were executed using Go 1.26.4 in an isolated sandbox and Go 1.27.2 in a separate Linux amd64 environment. All files were created in temporary test directories; no real save data was used. The tests were overlaid onto the pinned source without production-code changes. - Regression results (RED) on this revision:
TestAuditLive_ExtraRootPullRefusesToOverwriteWithoutSafetyBackupandTestAuditLive_ExtraRootDeleteRefusesWithoutSafetyBackup. The former replacesBASEwithREMOTE NEWand the latter removescritical.inidespite the deliberately unusable local backup destination. - Differential control (GREEN):
TestAuditControl_PrimaryRootRefusesOverwriteWithoutSafetyBackuppasses in that same sandbox, preserving the primary save's existingBASEbytes when its backup destination is unusable. The normal existing upstream conflict-resolution control also passes. - These tests demonstrate loss of the receiving device's prior working version without a successful local safety copy. They do not establish loss of every peer/archive copy or a real-world incident.
Root-cause analysis
The safety invariant was implemented in the primary-root orchestration path rather than in a shared pre-mutation boundary used by every root. Additional roots reuse the destructive helpers, but those helpers assume their caller has already performed any required safety admission; syncOneRoot does not do so.
This is not a bad sync decision: the remote replacement and propagated deletion are legitimate convergence actions when recovery protection exists. The defect is asymmetric admission policy across two paths that can perform the same classes of local mutation. As a result, an unusable backup destination stops the primary-root path but is never consulted by the additional-root path.
The narrow correction is to apply the same at-risk-file rule before mutating each additional location, while retaining location scope: a failure protecting one named location should stop that location without unnecessarily blocking safe new-file pulls or unrelated roots.
Suggested minimal fix and acceptance criteria
- Extend the existing primary-root at-risk-file admission rule to all additional named save locations before local overwrites or propagated deletions. Preserve ordinary sync of new/unchanged files that does not endanger existing content.
- If protection is required, obtain a successful snapshot that actually includes all endangered files; a ZIP with skipped required entries must not count as proof of recoverability.
- On failed admission, preserve local bytes and report a location-specific failure. Preventive acceptance criterion (not independently observed in these tests): do not advance lineage/agreed hashes for changes that were not safely applied or acknowledge successful additional-root application.
- Add negative tests for overwrite and deletion with unavailable backup storage, plus positive controls with writable storage, non-destructive new-file pulls, and normal deletion propagation.
- Keep a failure in one extra location scoped to that location; do not regress primary-root sync or unrelated named locations.
Related: #83 covers explicit user-directed additional-root conflict resolution, a separate entry point with a shared snapshot-safety concern. This report covers routine additional-root sync. A narrow shared safety-admission helper may be appropriate, without redesigning the sync protocol.
We would welcome the maintainer's preferred next step. If helpful, we can submit a draft PR with the regression coverage and a narrowly scoped fix, or help review an implementation.
Activity
Seagulls commentedon Oct 16, 2026
A self-contained negative-witness fixture is attached so these cases can be reproduced with synthetic loopback peers and no real save data:
https://gist.github.com/Seagulls/219f66e5381d14906cd41082a57c7891
Drop
additional_root_sync_repro_test.gointoe2e/on0b8d8bfec80329962f78df3f04bd1f0cb53b59ddand run:Confirmed on that commit with Go 1.26.4 and Go 1.27.2: both tests fail. They fail because their safety assertions reproduce the current additional-root overwrite and deletion behavior without a usable pre-replacement snapshot; after a fix, both should pass.
Omar-VF commentedon Oct 17, 2026
Thanks for the careful analysis and the reproduction fixture. Yes, please open the draft PR with your regression tests and a narrow fix, for both #82 and #83 (one PR or two, whichever is easier to review). Against
dev-public. A shared safety-admission helper for extra locations sounds right.Seagulls commentedon Oct 25, 2026
Rebased the open fixes onto current
dev-public(7e4310b).f4806ff. The only conflict was A save folder that is a link is synced as the folder it points at #110'skeepHeldFilesinsyncOneRoot. Both are kept: the hold filter runs first, then the verified safety admission, thenhandOverEmptying.541ee3c.keepHeldFilesandhandOverEmptyingonly adjust peer deletions, so they do not change the local at-risk set. Targeted-racesafety tests passed locally on Go 1.27.2; upstream CI is the remaining gate.Omar-VF commentedon Oct 27, 2026
Fixed by #92, now in
dev-public. An additional save location no longer overwrites or deletes local files unless a safety snapshot verifiably holds their current contents. It'll ship in the next release.