From aa6c88d496bec91fbe9c64f389b4289012614c36 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:36:28 +0200 Subject: [PATCH 01/54] Prepare 1.8.7 trust and portability contracts --- ROADMAP.md | 121 ++++++++++++++++++++++++++++++++++++- docs/wiki/Metadata-Sync.md | 27 +++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3bfbb8c9..516536eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -315,6 +315,8 @@ warnings, and fail closed when integrity metadata is missing or inconsistent. **Released:** 2026-08-10 **Tag:** `v1.8.6` **Release PR:** #532 +**Stable integration:** `430dc15` / PR #539 +**Published assets:** 18 qualified assets on 2026-08-10 Delivery note: `1.8.6` proceeds directly to the stable release after qualification. It does not have a beta build or prerelease GitHub release. @@ -366,23 +368,138 @@ History carry the same folder identity. ## 1.8.7 — Trust and Portability -**Status:** Planned +**Status:** Active planning; implementation begins after the release contracts and +cross-machine safety model are approved. **Tagline:** *Show the proof.* +**Target:** 2026-10-30 +**Working branch:** `release/1.8.7` +**Integration target:** `Dev` - [ ] `VS-1871` `P1` Expose build, channel, commit, runtime, architecture, package, and update-source information. + - Scope: define one build-information contract used by the desktop About and + diagnostics surfaces plus machine-readable CLI output; distinguish version, + channel, commit, runtime, architecture, package kind, update source, and + whether the build is official without treating unsigned packages as signed. + - Acceptance: a user or support bundle can identify the exact running build + without inspecting filenames, and unavailable values are shown as unknown + rather than guessed. - [ ] `VS-1872` `P0` Publish artifact checksums and a machine-readable release manifest from one release source of truth. + - Scope: generate version, channel, tag, commit, compatible predecessors, + asset names, platform, architecture, package kind, byte size, and SHA-256 + from the artifacts that are actually published; keep the manifest itself + outside its own digest set and validate every consumer against one schema. + - Acceptance: changing an asset, version, or digest makes release validation + fail, while an offline user can validate every downloaded package using the + published manifest and documented commands. - [ ] `VS-1873` `P1` Generate and publish a Software Bill of Materials and build provenance where supported. + - Scope: create an SBOM for each self-contained platform artifact, attest the + published artifact rather than an intermediate build directory, and expose + online and offline verification instructions. + - Acceptance: SBOM schemas validate, provenance binds each package to the + repository, workflow, and commit that produced it, and verification is + exercised in the release-candidate gate. - [ ] `VS-1874` `P1` Export a portable, checksummed Recovery Evidence Package. + - Scope: package a versioned JSON record, readable report, package manifest, + checksums, build identity, recovery state, drill evidence, and repository + identity without backup payloads, credentials, encryption secrets, or raw + unrestricted local paths. + - Acceptance: repeated exports of the same evidence are deterministic, + tampering is detected, schema compatibility is explicit, and the package + can be inspected without VaultSync. - [ ] `VS-1875` `P1` Strengthen explicitly redacted support bundles. + - Scope: define an allowlisted bundle schema, path pseudonymization, secret + denylist, size limits, and a review screen that lists every included file + and category before export. + - Acceptance: automated fixtures containing credentials, tokens, passwords, + user paths, and encryption material cannot leak them; users can cancel or + remove optional sections before the archive is written. - [ ] `VS-1876` `P1` Document repository layouts, manifests, encryption envelopes, compatibility, and emergency recovery expectations. -- [ ] `VS-1877` `P1` Add source-machine identity, repository writer locking, + - Scope: document supported repository records and versions, portable versus + machine-local fields, encryption descriptors, legacy behavior, manual + recovery, locks and leases, release verification, and failure recovery. + - Acceptance: documentation matches executable schemas and tests, includes a + clean-machine recovery path, and states every known compatibility limit. +- [ ] `VS-1877` `P0` Add source-machine identity, repository writer locking, and explicit dual-boot/concurrent-writer guidance. + - Scope: use a durable installation identity and a repository-scoped lease + with owner, operation, nonce, heartbeat, expiry, and application version; + allow safe read-only inspection, explicit stale takeover, and diagnostic + evidence without relying on process-local semaphores or machine names. + - Acceptance: two 1.8.7 clients cannot write concurrently, interrupted leases + recover predictably, NAS/SMB and clock-skew cases are covered, and the UI + states that pre-1.8.7 clients cannot cooperate with the lease protocol. - [ ] `VS-1878` `P1` Synchronize website, updater, changelog, Store metadata, badges, and public roadmap from canonical release metadata. + - Scope: make public and in-app release consumers derive from or validate + against the canonical release contract, including dry-run generation before + publication. + - Acceptance: CI rejects inconsistent public metadata and one unpublished + release-candidate run produces every expected consumer without publishing. +- [ ] `VS-1879` `P0` Replace two-way cross-machine settings import with a + versioned, reviewable, and reversible merge contract. + - Scope: persist a durable writer identity, per-record revision and base + revision, field-level portable-value provenance, and an explicit merge plan; + classify local-only fields separately, keep imports preview-only until the + user confirms conflicts, and make Keep local publish or remember a durable + resolution instead of rediscovering the same conflict. + - Acceptance: independent edits on two machines never silently overwrite one + another; non-overlapping changes merge, overlapping changes show old, local, + and remote values with timestamps and writers; accepting either side is + durable and auditable; the operation can be undone before the next write. + +### Confirmed defects entering 1.8.7 + +- [ ] `BUG-18098` `P1` Preserve complete wrapped roadmap ticket titles, scope, + and acceptance text when synchronizing GitHub issues and Project entries. + - Acceptance: parser fixtures cover multiline titles and nested scope bullets, + and a dry run reports exact changes without rewriting valid issue contracts. +- [ ] `BUG-18099` `P0` Service the .NET runtime and coordinated Microsoft + packages to the security-fixed `10.0.11` baseline or newer validated patch. + - Acceptance: all current runtime-pack Dependabot alerts are closed, direct + and runtime-pack vulnerability audits agree, and self-contained packages on + every supported RID contain the qualified runtime patch. +- [x] `BUG-18100` `P0` Restore the permanent `Dev` branch and prevent Stable + promotion merges from automatically deleting it. + - Completed: `Dev` was restored at the exact `v1.8.6` Stable commit on + 2026-08-12 and automatic head-branch deletion was disabled. +- [ ] `BUG-18101` `P0` Stop cross-machine metadata import from applying + unreviewed settings or repeatedly resurfacing a rejected remote edit. + - Scope: encryption policy and key references, auto-backup state, avatar + color, tombstones, and all existing conflict fields must follow an explicit + portability and conflict policy; Keep local must be durable. + - Acceptance: imports do not silently apply machine-local key references or + destructive tombstones, every changed portable field appears in preview, + writer attribution is record-specific, and resolved conflicts stay resolved. + +### Delivery sequence + +1. **12–16 August:** contracts, issue repair, threat model, metadata schema. +2. **17–30 August:** release manifest and in-app build identity. +3. **31 August–13 September:** platform SBOMs and artifact provenance. +4. **14–27 September:** durable machine identity, repository lease, and + cross-machine three-way merge. +5. **28 September–11 October:** evidence package, support bundle, and repository + documentation. +6. **12–18 October:** public metadata consumers, UI polish, localization, and + release dry run. +7. **19–25 October:** unpublished candidate, multi-machine/NAS exercises, and + the complete Windows, macOS, and Linux qualification matrix. +8. **30 October:** Stable release after every P0 and release gate is complete. + +### Release gates + +- every published asset is represented by an exact size and SHA-256 digest; +- platform SBOMs validate and build attestations verify online and offline; +- two 1.8.7 clients cannot write concurrently to one repository; +- divergent cross-machine edits are previewed and resolved without silent loss; +- upgrades from 1.8.6 preserve local projects, repositories, and recovery data; +- support and evidence exports pass adversarial privacy and tamper tests; +- all maintained translations, themes, accessibility paths, SonarQube, CodeQL, + dependency audits, and supported-platform builds pass. Signing and notarization remain desirable trust work, but availability and cost must not make truthful checksums, manifests, SBOMs, or provenance optional. diff --git a/docs/wiki/Metadata-Sync.md b/docs/wiki/Metadata-Sync.md index 1996917f..dd3738af 100644 --- a/docs/wiki/Metadata-Sync.md +++ b/docs/wiki/Metadata-Sync.md @@ -47,6 +47,33 @@ VaultSync can export a portable metadata store to backup destinations and later - In particular, preferred destination, restore mode, verification policy, and tags can create a metadata conflict record instead. - Review these conflicts from `Settings > Advanced > Doctor`. +### Current 1.8.6 limitations + +The 1.8.6 metadata store is a portable inventory and recovery aid, not a fully +synchronized multi-writer configuration database. + +- It compares the current local value with the latest value in the destination + store. It does not retain a common base revision, so it cannot prove which of + two independent edits is newer or automatically perform a true three-way + merge. +- The store-level writer machine is not record-level provenance. A conflict can + therefore identify the most recent store writer rather than the machine that + originally changed that specific project field. +- `Keep local` dismisses the current conflict record but does not publish a + durable resolution to the destination. The same unchanged remote value can be + discovered again by a later import. +- Encryption policy and key-reference metadata, auto-backup state, avatar color, + and tombstones do not all use the same review path as the four visible + conflict fields. Treat cross-machine imports as a review operation and avoid + editing the same project from multiple machines concurrently. +- The in-process metadata gate coordinates one running VaultSync process only. + It is not a cross-machine writer lock. + +VaultSync 1.8.7 tracks a versioned three-way merge contract, durable conflict +resolution, per-record writer provenance, and a repository-scoped writer lease. +Until that ships, use one machine as the writer for a destination and use other +machines for recovery inspection or deliberate imports. + ![Doctor, metadata-conflict, maintenance, and update controls](../images/Settings_Maintenance.png) ## Missing backup paths From 4df3442463a3c20918e82f92a65f556257638f57 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:05:10 +0200 Subject: [PATCH 02/54] Service the .NET 10 runtime baseline --- .github/workflows/ci.yml | 5 ++ .github/workflows/release-assets.yml | 17 +++++++ CHANGELOG.md | 4 ++ Directory.Build.props | 5 +- Directory.Packages.props | 6 +-- ROADMAP.md | 6 ++- global.json | 2 +- scripts/runtime_pack_audit.py | 62 ++++++++++++++++++++++++ src/VaultSync.Core/VaultSync.Core.csproj | 1 - src/VaultSync.UI/VaultSync.UI.csproj | 3 +- 10 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 scripts/runtime_pack_audit.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9ddc3f4..8e0b6d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,11 @@ jobs: - name: Build UI generic target run: dotnet build src/VaultSync.UI/VaultSync.UI.csproj --framework net10.0 --configuration Release --no-restore -warnaserror -p:UseSharedCompilation=false + - name: Publish and audit self-contained runtime + run: | + dotnet publish src/VaultSync.UI/VaultSync.UI.csproj --framework net10.0 --configuration Release --runtime linux-x64 --self-contained true --output "${{ runner.temp }}/runtime-audit" + python3 scripts/runtime_pack_audit.py --runtimeconfig "${{ runner.temp }}/runtime-audit/VaultSync.UI.runtimeconfig.json" + - name: Test run: dotnet test tests/VaultSync.Core.Tests/VaultSync.Core.Tests.csproj --configuration Release --no-restore -warnaserror -p:UseSharedCompilation=false diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 422b6a62..0e8524b4 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -284,6 +284,11 @@ jobs: -r win-x64 --self-contained true + - name: Verify embedded runtime (win-x64) + run: > + python scripts/runtime_pack_audit.py + --runtimeconfig src/VaultSync.UI/bin/Release/net10.0-windows10.0.19041.0/win-x64/publish/VaultSync.UI.runtimeconfig.json + - name: Install Inno Setup run: choco install innosetup -y --no-progress @@ -444,6 +449,12 @@ jobs: -r osx-x64 --self-contained true + - name: Verify embedded runtimes (macOS) + run: > + python3 scripts/runtime_pack_audit.py + --runtimeconfig src/VaultSync.UI/bin/Release/net10.0/osx-arm64/publish/VaultSync.UI.runtimeconfig.json + --runtimeconfig src/VaultSync.UI/bin/Release/net10.0/osx-x64/publish/VaultSync.UI.runtimeconfig.json + - name: Thin architecture-specific native libraries run: | bash scripts/thin_macos_native_libraries.sh arm64 src/VaultSync.UI/bin/Release/net10.0/osx-arm64/publish @@ -543,6 +554,12 @@ jobs: -r linux-arm64 --self-contained true + - name: Verify embedded runtimes (Linux) + run: > + python3 scripts/runtime_pack_audit.py + --runtimeconfig src/VaultSync.UI/bin/Release/net10.0/linux-x64/publish/VaultSync.UI.runtimeconfig.json + --runtimeconfig src/VaultSync.UI/bin/Release/net10.0/linux-arm64/publish/VaultSync.UI.runtimeconfig.json + - name: Build Linux archives env: TARGET_VERSION: ${{ inputs.target_version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c66f3189..dc37bcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## [Unreleased] +### Fixed +- [BUG-18099] Serviced the .NET SDK, self-contained runtime packs, and coordinated Microsoft packages to `10.0.11`, stopped ordinary restores from resolving unused cross-platform runtime packs, and added published-output gates that prevent vulnerable runtimes from bypassing the ordinary NuGet audit. + ## [1.8.6] - 10.08.2026 ### Added - [VS-1861] Replaced first-run overlays with a compact, resumable task sequence driven by real source, destination, project, schedule, restore-point, and passed recovery-drill state. diff --git a/Directory.Build.props b/Directory.Build.props index eed86209..30b039fd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,8 @@ - - 10.0.9 + + 10.0.11 + true diff --git a/Directory.Packages.props b/Directory.Packages.props index c4502bf5..5d2569ef 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,9 +6,9 @@ - + - + @@ -38,7 +38,7 @@ - + diff --git a/ROADMAP.md b/ROADMAP.md index 516536eb..22e21df5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -457,11 +457,15 @@ cross-machine safety model are approved. and acceptance text when synchronizing GitHub issues and Project entries. - Acceptance: parser fixtures cover multiline titles and nested scope bullets, and a dry run reports exact changes without rewriting valid issue contracts. -- [ ] `BUG-18099` `P0` Service the .NET runtime and coordinated Microsoft +- [x] `BUG-18099` `P0` Service the .NET runtime and coordinated Microsoft packages to the security-fixed `10.0.11` baseline or newer validated patch. - Acceptance: all current runtime-pack Dependabot alerts are closed, direct and runtime-pack vulnerability audits agree, and self-contained packages on every supported RID contain the qualified runtime patch. + - Completed: SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft + packages were pinned on 2026-08-12; unused cross-RID restore declarations + were removed, CI audits a real self-contained publish, and release jobs + verify the runtime embedded in every supported RID. - [x] `BUG-18100` `P0` Restore the permanent `Dev` branch and prevent Stable promotion merges from automatically deleting it. - Completed: `Dev` was restored at the exact `v1.8.6` Stable commit on diff --git a/global.json b/global.json index 34cc7a14..107ef70d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.303", "rollForward": "latestFeature" } } diff --git a/scripts/runtime_pack_audit.py b/scripts/runtime_pack_audit.py new file mode 100644 index 00000000..12b620c6 --- /dev/null +++ b/scripts/runtime_pack_audit.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Fail when restore or publish output resolves an unserviced .NET runtime.""" + +from __future__ import annotations + +import argparse +import json +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def version_tuple(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".")) + + +def configured_minimum(repo_root: Path) -> str: + root = ET.parse(repo_root / "Directory.Build.props").getroot() + element = root.find(".//VaultSyncMinimumRuntimeVersion") + if element is None or not element.text or not element.text.strip(): + raise ValueError("VaultSyncMinimumRuntimeVersion is not configured") + return element.text.strip() + + +def audit_runtimeconfig(path: Path, minimum: str) -> list[str]: + data = json.loads(path.read_text(encoding="utf-8-sig")) + frameworks = data.get("runtimeOptions", {}).get("includedFrameworks", []) + netcore = next( + (item for item in frameworks if item.get("name") == "Microsoft.NETCore.App"), + None, + ) + if netcore is None: + return [f"{path}: self-contained Microsoft.NETCore.App metadata is missing"] + version = netcore.get("version", "") + if version_tuple(version) < version_tuple(minimum): + return [f"{path}: embeds Microsoft.NETCore.App {version}; require >= {minimum}"] + return [] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--runtimeconfig", type=Path, action="append", required=True) + args = parser.parse_args() + + minimum = configured_minimum(args.repo_root) + errors: list[str] = [] + for path in args.runtimeconfig: + errors.extend(audit_runtimeconfig(path, minimum)) + + if errors: + print("Runtime security audit failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Runtime security audit passed (minimum {minimum}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/VaultSync.Core/VaultSync.Core.csproj b/src/VaultSync.Core/VaultSync.Core.csproj index fb98bd86..ec2a7261 100644 --- a/src/VaultSync.Core/VaultSync.Core.csproj +++ b/src/VaultSync.Core/VaultSync.Core.csproj @@ -2,7 +2,6 @@ net10.0 - win-x64;linux-x64;linux-arm64;osx-x64;osx-arm64 enable enable diff --git a/src/VaultSync.UI/VaultSync.UI.csproj b/src/VaultSync.UI/VaultSync.UI.csproj index 8fd572d3..f38dc3a6 100644 --- a/src/VaultSync.UI/VaultSync.UI.csproj +++ b/src/VaultSync.UI/VaultSync.UI.csproj @@ -8,7 +8,6 @@ net10.0;net10.0-windows10.0.19041.0 - win-x64;linux-x64;linux-arm64;osx-x64;osx-arm64 enable true @@ -25,7 +24,7 @@ $(DefineConstants);WINDOWS true - + win-x64 win-x64 From e14ea1150c7e88bc1bdeffdb7df004a21e40a041 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:06:56 +0200 Subject: [PATCH 03/54] Keep release metadata on the packaged version --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc37bcd3..c66f3189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,4 @@ # Changelog -## [Unreleased] -### Fixed -- [BUG-18099] Serviced the .NET SDK, self-contained runtime packs, and coordinated Microsoft packages to `10.0.11`, stopped ordinary restores from resolving unused cross-platform runtime packs, and added published-output gates that prevent vulnerable runtimes from bypassing the ordinary NuGet audit. - ## [1.8.6] - 10.08.2026 ### Added - [VS-1861] Replaced first-run overlays with a compact, resumable task sequence driven by real source, destination, project, schedule, restore-point, and passed recovery-drill state. From bff3e8799992f306a455fc06d92d159c5cfbb4fd Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:16:06 +0200 Subject: [PATCH 04/54] Cover the runtime security audit --- scripts/runtime_pack_audit.py | 16 ++-- tests/scripts/test_runtime_pack_audit.py | 106 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/scripts/test_runtime_pack_audit.py diff --git a/scripts/runtime_pack_audit.py b/scripts/runtime_pack_audit.py index 12b620c6..aa14540c 100644 --- a/scripts/runtime_pack_audit.py +++ b/scripts/runtime_pack_audit.py @@ -5,21 +5,27 @@ import argparse import json +import re import sys -import xml.etree.ElementTree as ET from pathlib import Path +MINIMUM_VERSION_PROPERTY = re.compile( + r"\s*(\d+\.\d+\.\d+)\s*" + r"" +) + + def version_tuple(value: str) -> tuple[int, ...]: return tuple(int(part) for part in value.split(".")) def configured_minimum(repo_root: Path) -> str: - root = ET.parse(repo_root / "Directory.Build.props").getroot() - element = root.find(".//VaultSyncMinimumRuntimeVersion") - if element is None or not element.text or not element.text.strip(): + content = (repo_root / "Directory.Build.props").read_text(encoding="utf-8-sig") + match = MINIMUM_VERSION_PROPERTY.search(content) + if match is None: raise ValueError("VaultSyncMinimumRuntimeVersion is not configured") - return element.text.strip() + return match.group(1) def audit_runtimeconfig(path: Path, minimum: str) -> list[str]: diff --git a/tests/scripts/test_runtime_pack_audit.py b/tests/scripts/test_runtime_pack_audit.py new file mode 100644 index 00000000..656e7d1b --- /dev/null +++ b/tests/scripts/test_runtime_pack_audit.py @@ -0,0 +1,106 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = REPO_ROOT / "scripts" / "runtime_pack_audit.py" + +spec = importlib.util.spec_from_file_location("runtime_pack_audit", MODULE_PATH) +runtime_pack_audit = importlib.util.module_from_spec(spec) +assert spec is not None and spec.loader is not None +spec.loader.exec_module(runtime_pack_audit) + + +class RuntimePackAuditTests(unittest.TestCase): + def write_repo(self, root: Path, minimum: str = "10.0.11") -> None: + (root / "Directory.Build.props").write_text( + "" + f"{minimum}" + "", + encoding="utf-8", + ) + + def write_runtimeconfig(self, path: Path, version: str | None) -> None: + frameworks = [] if version is None else [ + {"name": "Microsoft.NETCore.App", "version": version} + ] + path.write_text( + json.dumps({"runtimeOptions": {"includedFrameworks": frameworks}}), + encoding="utf-8", + ) + + def test_configured_minimum_reads_the_repository_property(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.write_repo(root, "10.0.12") + self.assertEqual("10.0.12", runtime_pack_audit.configured_minimum(root)) + + def test_configured_minimum_rejects_a_missing_property(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "Directory.Build.props").write_text("", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "not configured"): + runtime_pack_audit.configured_minimum(root) + + def test_audit_runtimeconfig_accepts_the_minimum_or_newer(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "app.runtimeconfig.json" + self.write_runtimeconfig(path, "10.0.12") + self.assertEqual([], runtime_pack_audit.audit_runtimeconfig(path, "10.0.11")) + + def test_audit_runtimeconfig_rejects_an_old_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "app.runtimeconfig.json" + self.write_runtimeconfig(path, "10.0.8") + errors = runtime_pack_audit.audit_runtimeconfig(path, "10.0.11") + self.assertEqual(1, len(errors)) + self.assertIn("embeds Microsoft.NETCore.App 10.0.8", errors[0]) + + def test_audit_runtimeconfig_requires_self_contained_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "app.runtimeconfig.json" + self.write_runtimeconfig(path, None) + errors = runtime_pack_audit.audit_runtimeconfig(path, "10.0.11") + self.assertEqual(1, len(errors)) + self.assertIn("metadata is missing", errors[0]) + + def test_main_returns_success_for_a_serviced_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = root / "app.runtimeconfig.json" + self.write_repo(root) + self.write_runtimeconfig(config, "10.0.11") + output = StringIO() + with patch.object( + sys, + "argv", + ["runtime_pack_audit.py", "--repo-root", str(root), "--runtimeconfig", str(config)], + ), redirect_stdout(output): + self.assertEqual(0, runtime_pack_audit.main()) + self.assertIn("Runtime security audit passed", output.getvalue()) + + def test_main_returns_failure_for_an_unserviced_runtime(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = root / "app.runtimeconfig.json" + self.write_repo(root) + self.write_runtimeconfig(config, "10.0.8") + output = StringIO() + with patch.object( + sys, + "argv", + ["runtime_pack_audit.py", "--repo-root", str(root), "--runtimeconfig", str(config)], + ), redirect_stderr(output): + self.assertEqual(1, runtime_pack_audit.main()) + self.assertIn("require >= 10.0.11", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() From e5add6df13ee5cdd593f839967c00698f7a53038 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:23:19 +0200 Subject: [PATCH 05/54] Restrict runtime audit inputs --- scripts/runtime_pack_audit.py | 25 ++++++++++++++++---- tests/scripts/test_runtime_pack_audit.py | 29 +++++++++++++++++------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/scripts/runtime_pack_audit.py b/scripts/runtime_pack_audit.py index aa14540c..2eac2130 100644 --- a/scripts/runtime_pack_audit.py +++ b/scripts/runtime_pack_audit.py @@ -5,8 +5,10 @@ import argparse import json +import os import re import sys +import tempfile from pathlib import Path @@ -14,6 +16,7 @@ r"\s*(\d+\.\d+\.\d+)\s*" r"" ) +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] def version_tuple(value: str) -> tuple[int, ...]: @@ -28,6 +31,20 @@ def configured_minimum(repo_root: Path) -> str: return match.group(1) +def resolve_runtimeconfig(path_text: str, repo_root: Path) -> Path: + """Resolve runtime metadata without allowing arbitrary filesystem reads.""" + candidate = Path(path_text).expanduser().resolve(strict=True) + allowed_roots = [repo_root.resolve(strict=True), Path(tempfile.gettempdir()).resolve(strict=True)] + runner_temp = os.environ.get("RUNNER_TEMP") + if runner_temp: + allowed_roots.append(Path(runner_temp).resolve(strict=True)) + if not any(candidate.is_relative_to(root) for root in allowed_roots): + raise ValueError(f"runtimeconfig must stay inside the repository or runner temp: {path_text}") + if not candidate.is_file() or not candidate.name.endswith(".runtimeconfig.json"): + raise ValueError(f"runtimeconfig must be an existing .runtimeconfig.json file: {path_text}") + return candidate + + def audit_runtimeconfig(path: Path, minimum: str) -> list[str]: data = json.loads(path.read_text(encoding="utf-8-sig")) frameworks = data.get("runtimeOptions", {}).get("includedFrameworks", []) @@ -45,13 +62,13 @@ def audit_runtimeconfig(path: Path, minimum: str) -> list[str]: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) - parser.add_argument("--runtimeconfig", type=Path, action="append", required=True) + parser.add_argument("--runtimeconfig", action="append", required=True) args = parser.parse_args() - minimum = configured_minimum(args.repo_root) + minimum = configured_minimum(REPOSITORY_ROOT) errors: list[str] = [] - for path in args.runtimeconfig: + for path_text in args.runtimeconfig: + path = resolve_runtimeconfig(path_text, REPOSITORY_ROOT) errors.extend(audit_runtimeconfig(path, minimum)) if errors: diff --git a/tests/scripts/test_runtime_pack_audit.py b/tests/scripts/test_runtime_pack_audit.py index 656e7d1b..f70a14d3 100644 --- a/tests/scripts/test_runtime_pack_audit.py +++ b/tests/scripts/test_runtime_pack_audit.py @@ -71,6 +71,23 @@ def test_audit_runtimeconfig_requires_self_contained_metadata(self) -> None: self.assertEqual(1, len(errors)) self.assertIn("metadata is missing", errors[0]) + def test_resolve_runtimeconfig_rejects_paths_outside_allowed_roots(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as repo_tmp: + with tempfile.TemporaryDirectory() as outside_tmp: + path = Path(outside_tmp) / "app.runtimeconfig.json" + self.write_runtimeconfig(path, "10.0.11") + with patch.object(runtime_pack_audit.tempfile, "gettempdir", return_value=repo_tmp): + with self.assertRaisesRegex(ValueError, "must stay inside"): + runtime_pack_audit.resolve_runtimeconfig(path, Path(repo_tmp)) + + def test_resolve_runtimeconfig_requires_the_expected_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "metadata.json" + self.write_runtimeconfig(path, "10.0.11") + with self.assertRaisesRegex(ValueError, r"\.runtimeconfig\.json"): + runtime_pack_audit.resolve_runtimeconfig(path, root) + def test_main_returns_success_for_a_serviced_runtime(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -78,10 +95,8 @@ def test_main_returns_success_for_a_serviced_runtime(self) -> None: self.write_repo(root) self.write_runtimeconfig(config, "10.0.11") output = StringIO() - with patch.object( - sys, - "argv", - ["runtime_pack_audit.py", "--repo-root", str(root), "--runtimeconfig", str(config)], + with patch.object(runtime_pack_audit, "REPOSITORY_ROOT", root), patch.object( + sys, "argv", ["runtime_pack_audit.py", "--runtimeconfig", str(config)] ), redirect_stdout(output): self.assertEqual(0, runtime_pack_audit.main()) self.assertIn("Runtime security audit passed", output.getvalue()) @@ -93,10 +108,8 @@ def test_main_returns_failure_for_an_unserviced_runtime(self) -> None: self.write_repo(root) self.write_runtimeconfig(config, "10.0.8") output = StringIO() - with patch.object( - sys, - "argv", - ["runtime_pack_audit.py", "--repo-root", str(root), "--runtimeconfig", str(config)], + with patch.object(runtime_pack_audit, "REPOSITORY_ROOT", root), patch.object( + sys, "argv", ["runtime_pack_audit.py", "--runtimeconfig", str(config)] ), redirect_stderr(output): self.assertEqual(1, runtime_pack_audit.main()) self.assertIn("require >= 10.0.11", output.getvalue()) From 8aa66fe83eaaca17244f47ec90d9ca60e8890f21 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:52:42 +0200 Subject: [PATCH 06/54] Define the 1.8.7 trust contracts --- DOCUMENTATION.md | 23 ++++++ ROADMAP.md | 8 +- docs/CROSS_MACHINE_SAFETY.md | 146 +++++++++++++++++++++++++++++++++++ docs/README.md | 5 ++ docs/RELEASE_1.8.7.md | 121 +++++++++++++++++++++++++++++ docs/RELEASING.md | 30 +++---- docs/REPOSITORY_FORMATS.md | 123 +++++++++++++++++++++++++++++ docs/wiki/Metadata-Sync.md | 7 ++ 8 files changed, 447 insertions(+), 16 deletions(-) create mode 100644 docs/CROSS_MACHINE_SAFETY.md create mode 100644 docs/RELEASE_1.8.7.md create mode 100644 docs/REPOSITORY_FORMATS.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 57fc83d4..6bcdb9a6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -29,6 +29,11 @@ Core pillars: ### 2.2 Operational docs - `docs/HELP.md`: in-app help target and concise user guidance. - `docs/RELEASING.md`: release packaging/publishing flow. +- `docs/RELEASE_1.8.7.md`: active-release status, contracts, sequencing, and gates. +- `docs/REPOSITORY_FORMATS.md`: repository layouts, compatibility boundaries, + and emergency read-only recovery guidance. +- `docs/CROSS_MACHINE_SAFETY.md`: cross-machine threat model, identity, + repository lease, merge, and recovery contracts. - `docs/UPDATER.md`: patch asset contract and update flow. - `docs/WHATS_NEW.md`: user-facing release highlights. - `docs/DISASTER_RECOVERY.md`: recovery proofs, drills, 3-2-1 guidance, and protection behavior. @@ -42,6 +47,8 @@ Core pillars: - `docs/wiki/Home.md`: wiki entry page. - `docs/wiki/*`: task and feature guides (installation, backups, destinations, troubleshooting, etc.). - `docs/wiki/Encryption.md`: backup encryption setup, format, credential storage, password changes, opening, and restore. +- `docs/wiki/Metadata-Sync.md`: portable metadata behavior, current limitations, + and cross-machine safety guidance. ## 3. Work-Item and ID Conventions Primary planning IDs use `VS-xxxx`. @@ -127,3 +134,19 @@ For the `1.8` Chronicle release line, keep these areas aligned: - privacy-first crash-report assistance (`docs/PRIVACY.md`, `docs/CRASH_REPORTING.md`) - update, packaging, and release behavior (`docs/UPDATER.md`, `docs/MICROSOFT_STORE.md`, `docs/RELEASING.md`) - release highlights (`docs/WHATS_NEW.md`, `CHANGELOG.md`) + +## 10. Active 1.8.7 Documentation Contract + +VaultSync 1.8.7 is in development. Use `docs/RELEASE_1.8.7.md` as the status +page and `ROADMAP.md` as the canonical scope. Planned behavior must stay labeled +as planned until its implementation, tests, and user documentation land. + +Every repository-format or metadata-sync change must update, in the same +logical commit: + +- `docs/REPOSITORY_FORMATS.md` for on-disk schema and compatibility; +- `docs/CROSS_MACHINE_SAFETY.md` for identity, lease, and merge invariants; +- `docs/wiki/Metadata-Sync.md` for user-visible behavior and safety guidance; +- this file for the exported field-level contract; +- executable migration and regression tests; +- `CHANGELOG.md` only when the behavior exists on the release branch. diff --git a/ROADMAP.md b/ROADMAP.md index 22e21df5..5a666e90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -368,13 +368,17 @@ History carry the same folder identity. ## 1.8.7 — Trust and Portability -**Status:** Active planning; implementation begins after the release contracts and -cross-machine safety model are approved. +**Status:** Active development. Release contracts were approved and implementation +started on 2026-08-12. **Tagline:** *Show the proof.* **Target:** 2026-10-30 **Working branch:** `release/1.8.7` **Integration target:** `Dev` +The maintained implementation status and safety contracts for this release live +in `docs/RELEASE_1.8.7.md`. That page distinguishes shipped behavior from work +that exists only on the release branch or remains planned. + - [ ] `VS-1871` `P1` Expose build, channel, commit, runtime, architecture, package, and update-source information. - Scope: define one build-information contract used by the desktop About and diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md new file mode 100644 index 00000000..0b3a5fb1 --- /dev/null +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -0,0 +1,146 @@ +# Cross-Machine Metadata Safety + +This is the design and threat-model contract for the 1.8.7 writer lease and +versioned metadata merge. It does not claim that planned behavior is available; +implementation status is maintained in [the 1.8.7 release page](RELEASE_1.8.7.md). + +## Problem statement + +A destination can be reachable from two installations through a local mount, +NAS, SMB share, dual-boot system, or synchronized directory. Host names, +process-local locks, and last-write timestamps are not enough to decide who may +write or whose setting is authoritative. A safe design must prevent cooperating +clients from writing concurrently and must never resolve divergent edits by +silently selecting the last value observed. + +## Protected assets + +- readable backup payloads and their mapping to projects and snapshots; +- portable project settings and deletion history; +- encryption descriptors without secrets; +- durable conflict decisions and record provenance; +- evidence needed to explain which installation performed a write. + +## Threat and failure cases + +The protocol must handle: + +- two 1.8.7 clients starting a write at nearly the same time; +- a crash, power loss, forced termination, or network loss during a write; +- delayed, cached, or reordered NAS/SMB observations; +- wall-clock skew between installations; +- host rename, operating-system reinstall, cloned config, and dual boot; +- a valid writer performing a long operation; +- a stale lease whose former owner later reconnects; +- a pre-1.8.7 client that ignores the protocol; +- independent edits to different fields and to the same field; +- repeated imports after Keep local or Accept remote; +- tombstones and machine-local encryption-key references. + +The protocol is a reliability and coordination boundary between cooperating +clients, not a defense against a malicious administrator who can rewrite the +repository. + +## Installation identity + +- A cryptographically random identifier is created once in the private local + application-data directory. +- The canonical serialized form is a lowercase 32-character GUID without + punctuation. +- The file is owner-private where the platform supports Unix permissions. +- A missing identity may be created atomically. A malformed existing identity is + reported as corruption and must not be silently replaced. +- Identity is independent of telemetry, opt-in state, account name, and mutable + host name. +- Diagnostic UI may show a short prefix and the host name as a friendly label; + the full durable identifier remains the authority. + +Copying an application-data directory clones its identity. Before repository +lease rollout is complete, the implementation must detect a lease claiming the +same identity with a different active nonce and treat it as a conflict rather +than assuming it is the same process. + +## Writer lease + +The repository stores one coordination record containing: + +| Field | Meaning | +|---|---| +| protocol version | Parser and compatibility boundary | +| installation id | Durable owner identity | +| host label | Diagnostic display only | +| process id | Local diagnostic hint only | +| operation | Export, tombstone, migration, repair, or other write class | +| nonce | Random acquisition identity; prevents an old owner releasing a new lease | +| app version | Writer compatibility evidence | +| acquired UTC | Diagnostic timestamp | +| heartbeat UTC | Most recently renewed writer timestamp | +| expires UTC | Conservative stale threshold | + +Acquisition uses create-if-absent semantics. If a valid unexpired record exists, +the second client receives a busy result and may continue read-only. A lease +holder renews before one third of the lease duration elapses. Release succeeds +only when installation id and nonce still match the on-disk record. + +Expiry is evidence that a lease may be stale, not permission for invisible +takeover. The user must explicitly confirm takeover; the old record is preserved +as diagnostic evidence before a new lease is acquired. A former owner whose +nonce no longer matches must abort before committing another write. + +Clock-skew qualification includes clients offset in both directions. Expiry +decisions use conservative tolerance and observable record age where available; +they never use a future timestamp as proof that takeover is safe. + +## Metadata merge + +Each portable record needs: + +- stable record identity; +- monotonically advancing revision scoped to that record; +- base revision from which an edit was made; +- durable writer identity and write timestamp; +- per-field value and portability classification; +- durable resolution record when a conflict is decided. + +Given base `B`, local `L`, and remote `R`: + +- if only one side differs from `B`, select that changed side; +- if both sides change different portable fields, combine them in preview; +- if both sides change the same field to the same value, accept the value once; +- if both sides change the same field differently, require explicit review; +- if the base is unknown, do not infer causality from timestamps alone. + +Machine-local fields, including an encryption key reference that names a local +credential, are never auto-applied on another installation. Tombstones remain +preview-only until their affected entities and payload implications are shown. + +Keep local publishes or records a resolution tied to the remote revision. +Accept remote records the inverse decision. The same unchanged pair must not +reappear. An undo record is valid only until a later write advances the affected +revision. + +## Safe rollout order + +1. Land and test durable installation identity without changing repository data. +2. Add lease parsing and read-only busy diagnostics. +3. Protect every metadata writer, including tombstones and repair/migration. +4. Add the versioned schema and forward migration fixtures. +5. Produce merge plans without applying them. +6. Add explicit apply, durable resolution, and bounded undo. +7. Expose status, takeover, and conflict review in the UI. +8. Qualify local disk, SMB/NAS, disconnection, skew, crash, and mixed-version + scenarios before enabling multi-machine writes by default. + +## Non-negotiable tests + +- concurrent identity creation returns one durable value; +- malformed identity fails closed; +- simultaneous lease acquisition produces exactly one writer; +- a mismatched nonce cannot renew, release, or commit; +- a reader remains available while a writer holds the lease; +- crash and expiry require explicit takeover and preserve evidence; +- version-1 repository migration preserves all records; +- independent edits merge and overlapping edits never silently overwrite; +- Keep local, Accept remote, and undo remain durable across restart; +- local key references and destructive tombstones never bypass preview; +- mixed 1.8.6/1.8.7 guidance is visible and tested where the UI exposes it. diff --git a/docs/README.md b/docs/README.md index 52c1556a..dc08cb52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ Use this page as the primary index for all project documentation. - Roadmap: [ROADMAP](../ROADMAP.md) - Changelog: [CHANGELOG](../CHANGELOG.md) - Current release highlights: [What's New](WHATS_NEW.md) +- Active 1.8.7 development status: [1.8.7 release contract](RELEASE_1.8.7.md) - Release process: [Releasing](RELEASING.md) - Updater and patch assets: [Updater](UPDATER.md) - Microsoft Store planning and packaging notes: [Microsoft Store](MICROSOFT_STORE.md) @@ -29,6 +30,10 @@ Use this page as the primary index for all project documentation. - Crash reporting and user control: [Crash reporting](CRASH_REPORTING.md) - Disaster recovery drills and 3-2-1 advisor: [Disaster recovery](DISASTER_RECOVERY.md) - Native recoverability engine and ProofRestore provenance: [Recoverability engine](RECOVERABILITY_ENGINE.md) +- Repository formats, compatibility, and emergency inspection: + [Repository formats](REPOSITORY_FORMATS.md) +- Cross-machine threat model and safety contract: + [Cross-machine safety](CROSS_MACHINE_SAFETY.md) - Code of Conduct: [CODE_OF_CONDUCT](../CODE_OF_CONDUCT.md) - SonarQube Cloud setup: [SonarQube](SONARQUBE.md) diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md new file mode 100644 index 00000000..a345880c --- /dev/null +++ b/docs/RELEASE_1.8.7.md @@ -0,0 +1,121 @@ +# VaultSync 1.8.7 — Trust and Portability + +This is the maintained implementation-status page for the active `1.8.7` +release. The canonical feature scope and acceptance criteria remain in +[`ROADMAP.md`](../ROADMAP.md#187--trust-and-portability). + +## Release identity + +| Field | Value | +|---|---| +| Current stable | `1.8.6` (`v1.8.6`, released 2026-08-10) | +| Active target | `1.8.7` | +| Planning started | 2026-08-12 | +| Stable target | 2026-10-30 | +| Working branch | `release/1.8.7` | +| Integration branch | `Dev` | +| Stable branch | `Stable` | +| Release PR | [#546](https://github.com/ATAC-Helicopter/VaultSync/pull/546) | +| Tagline | *Show the proof.* | + +The release branch accumulates the qualified 1.8.7 work. `Dev` is the +integration branch; `Stable` represents shipped releases only. A beta is not +assumed and must be approved explicitly if the release needs one. + +## Status as of 2026-08-12 + +### Implemented on the release branch + +- The .NET SDK is pinned to `10.0.303` and the supported runtime baseline is + `10.0.11`. +- Coordinated Microsoft runtime packages are pinned to the serviced baseline. +- CI audits a real self-contained publish and release jobs validate the runtime + embedded in every supported RID. +- The permanent `Dev` branch was restored at the `v1.8.6` Stable commit and + automatic head-branch deletion was disabled. + +These changes are not shipped until the release work reaches `Stable`. +Dependabot can therefore continue to report the old default-branch runtime +until promotion; that is a branch-state difference, not an unaddressed release- +branch package. + +### In progress next + +1. Complete the repository and cross-machine threat model. +2. Introduce durable installation identity and repository-scoped writer safety. +3. Make metadata imports previewable, versioned, durable, and reversible. +4. Generate the release manifest and expose complete build identity. + +### Still planned + +- per-platform SBOMs and supported build provenance; +- checksummed Recovery Evidence Packages; +- allowlisted, reviewable support bundles; +- synchronized public release metadata; +- full repository and emergency-recovery documentation after schemas stabilize; +- final localization, theme, accessibility, static-analysis, dependency, and + cross-platform release qualification. + +## Safety contracts + +### Distribution trust + +- Every published asset must have an exact byte size and SHA-256 digest in one + machine-readable manifest generated from the final artifact. +- An unavailable digest, inconsistent version, or unexpected asset must fail + release validation. +- Unsigned direct downloads must never be described as signed or notarized. + +### Repository writing + +- Installation identity must be durable, random, local, and independent of a + mutable host name. +- A repository lease must identify owner, operation, nonce, application + version, acquisition time, heartbeat, and expiry. +- Read-only inspection remains possible while a valid writer exists. +- Stale takeover must be explicit and leave diagnostic evidence. +- Pre-1.8.7 clients do not understand the lease protocol and cannot safely + cooperate as concurrent writers. + +### Cross-machine metadata + +- Imports must be preview-only until destructive or conflicting changes are + explicitly accepted. +- Machine-local secret references cannot be silently applied elsewhere. +- Non-overlapping portable edits may merge; overlapping edits must show base, + local, and remote values with writer and timestamp provenance. +- Keep local and accept remote must create durable resolutions so the same + unchanged conflict does not return. +- A confirmed merge can be undone until a later repository write supersedes it. + +### Evidence and support exports + +- Exports use an allowlist, not a denylist alone. +- Credentials, tokens, plaintext passwords, encryption secrets, and unrestricted + local paths are forbidden. +- Users see exactly what will be included before the archive is created. +- Evidence packages are versioned, deterministic, checksummed, and readable + without VaultSync. + +## Definition of done + +1. Every P0 roadmap item and confirmed P0 defect is complete. +2. Behavior, executable schema tests, user documentation, and release notes + agree. +3. Upgrade and recovery exercises start from an unmodified 1.8.6 installation. +4. Two machines and representative NAS/SMB storage pass writer, expiry, + conflict, clock-skew, interruption, and read-only inspection scenarios. +5. Windows, macOS, Linux, all maintained translations, themes, accessibility, + SonarQube, CodeQL, and dependency gates pass. +6. An unpublished stable candidate produces and validates every expected asset + and public metadata consumer before promotion. + +## Maintainer links + +- [Roadmap](../ROADMAP.md#187--trust-and-portability) +- [Release procedure](RELEASING.md) +- [Repository formats](REPOSITORY_FORMATS.md) +- [Cross-machine safety](CROSS_MACHINE_SAFETY.md) +- [Metadata sync](wiki/Metadata-Sync.md) +- [Security policy](../SECURITY.md) +- [Updater contract](UPDATER.md) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a6c5685c..15b9b78d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,9 +6,11 @@ This document defines the current release packaging flow. - .NET 10 SDK - Inno Setup (Windows installer) - Repo version/changelog already updated for the target release -- The prepared stable release target is `1.8.6`. -- `1.8.6` ships directly as a stable release. There are no `1.8.6-Beta.N` - builds or prerelease GitHub releases. +- The current stable release is `1.8.6`. +- The active development target is `1.8.7` on `release/1.8.7`, integrating + through `Dev` and promoted to `Stable` only after its release gates pass. +- Do not create a beta or prerelease implicitly. A prerelease requires an + explicit release decision, a version suffix, and the beta workflow inputs. ## 1) Windows Installer 1. Publish: @@ -35,8 +37,8 @@ This document defines the current release packaging flow. ``` 2. Build Linux archives: ```bash - bash scripts/build_linux_release.sh 1.8.6 x64 src/VaultSync.UI/bin/Release/net10.0/linux-x64/publish - bash scripts/build_linux_release.sh 1.8.6 arm64 src/VaultSync.UI/bin/Release/net10.0/linux-arm64/publish + bash scripts/build_linux_release.sh 1.8.7 x64 src/VaultSync.UI/bin/Release/net10.0/linux-x64/publish + bash scripts/build_linux_release.sh 1.8.7 arm64 src/VaultSync.UI/bin/Release/net10.0/linux-arm64/publish ``` 3. Upload the generated `.tar.gz`, `.deb`, and `linux-x64` `.AppImage` artifacts. The `.tar.gz` archives include `install.sh` and `uninstall.sh` for a @@ -57,15 +59,15 @@ Patch automation accepts one qualified predecessor: Stable example: - branch: `Stable` - release channel: `stable` -- `previous_version = 1.8.5` -- `target_version = 1.8.6` +- `previous_version = 1.8.6` +- `target_version = 1.8.7` Pre-merge release candidate example: -- branch: `release/1.8.6` +- branch: `release/1.8.7` - release channel: `stable` - `release_candidate = true` -- `previous_version = 1.8.5` -- `target_version = 1.8.6` +- `previous_version = 1.8.6` +- `target_version = 1.8.7` - candidate artifacts remain GitHub Actions artifacts; do not attach them to a non-prerelease GitHub Release until the release PR is approved and merged into `Stable` @@ -74,11 +76,11 @@ This mode builds the exact stable-version binaries from the release branch without merging the release PR. The workflow rejects a candidate build unless the branch name exactly matches `release/`. -Future prerelease example (not used for `1.8.6`): +Optional prerelease example (only after an explicit release decision): - branch: `Dev` after the beta changes are merged there - release channel: `beta` - `release_candidate = false` -- `previous_version = 1.8.5` +- `previous_version = 1.8.6` - `target_version = -Beta.1` - `include_linux_patches = false` when the previous Linux build can be installed under `/opt/vaultsync`, so Linux users receive installer fallback instead of an unwritable patch apply. @@ -95,11 +97,11 @@ Do not broaden the allowlist to older releases without a separate qualification ## 5) Release Checklist - Run the release gate before publishing: ```powershell - powershell -ExecutionPolicy Bypass -File scripts/release_readiness_gate.ps1 -TargetVersion 1.8.6 -ReleaseTrack 1.8.x -TargetMilestone 1.8.6 + powershell -ExecutionPolicy Bypass -File scripts/release_readiness_gate.ps1 -TargetVersion 1.8.7 -ReleaseTrack 1.8.x -TargetMilestone 1.8.7 ``` - Run the release gate again after GitHub Actions uploads assets: ```powershell - powershell -ExecutionPolicy Bypass -File scripts/release_readiness_gate.ps1 -TargetVersion 1.8.6 -ReleaseTrack 1.8.x -TargetMilestone 1.8.6 -Phase PostPublish + powershell -ExecutionPolicy Bypass -File scripts/release_readiness_gate.ps1 -TargetVersion 1.8.7 -ReleaseTrack 1.8.x -TargetMilestone 1.8.7 -Phase PostPublish ``` - `CHANGELOG.md` updated - `docs/WHATS_NEW.md` updated diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md new file mode 100644 index 00000000..72417943 --- /dev/null +++ b/docs/REPOSITORY_FORMATS.md @@ -0,0 +1,123 @@ +# VaultSync Repository Formats and Recovery Boundary + +This document records the current on-disk contracts and the compatibility work +planned for VaultSync 1.8.7. Sections labeled **Current** describe implemented +1.8.6-compatible behavior. Sections labeled **Planned for 1.8.7** are design +contracts and must not be treated as available until their implementation and +tests land. + +## Storage map + +| Location | Purpose | Portability | +|---|---|---| +| Application database | Local projects, snapshots, backups, and application state | Machine-local | +| Application configuration | UI, destinations, schedules, and operational preferences | Machine-local | +| `/.vaultsync/meta/vaultsync.meta.db` | Portable project and backup-history metadata | Cross-machine | +| Backup payload folders/archives | Recoverable project bytes | Cross-machine when the destination is reachable | +| Recovery evidence reports | Readable proof and drill summaries | Exportable, redacted | + +The application database and configuration are not a shared multi-writer +database. Copying them between live installations is not a supported sync +mechanism. + +## Portable metadata store — Current + +The SQLite metadata store uses schema version `1`. Its logical tables are: + +- `meta_info`: schema version, creation/write timestamps, writer app version, + and the most recent store-level writer machine value; +- `projects`: external identity, name, preset, root-path hint, timestamps, and + JSON-encoded project settings; +- `snapshots`: external/project identities, creation time, counts, sizes, and + diff summaries; +- `backups`: external/project/snapshot identities, creation time, backup type + and mode, relative path, destination alias, source-machine display name, + protection state, encryption flag, and non-secret descriptor JSON; +- `tombstones`: entity type, external identity, deletion time, and origin + machine value. + +`settings_json` currently includes portable-looking values such as avatar color, +encryption policy and key reference, preferred destination, restore mode, +verification policy, auto-backup state, and tags. Not every one of these fields +is safe to apply automatically on another machine; the current limitation and +single-writer guidance are documented in +[Metadata Sync](wiki/Metadata-Sync.md#current-186-limitations). + +### Current compatibility behavior + +- Unknown future schema versions are rejected rather than guessed. +- Older stores are extended with known additive columns when opened for write. +- Rooted paths from another machine are normalized or treated as hints; they are + not authoritative local paths. +- Plaintext credentials and backup payload contents are not stored in the + metadata database. +- The store-wide writer value identifies the latest store writer, not the author + of every individual field. +- Process-local semaphores serialize one VaultSync process only and do not + protect a repository from another machine. + +## Repository coordination — Planned for 1.8.7 + +The next schema revision will add, with explicit migrations and fixtures: + +- durable installation identity distinct from host name; +- per-record revision, base revision, writer identity, and timestamp; +- field-level portability and provenance for project settings; +- durable conflict-resolution records; +- a repository-scoped writer lease with owner, operation, nonce, heartbeat, + expiry, and application version. + +The migration must preserve every readable version-1 record. A 1.8.7 client may +inspect a repository read-only while another valid lease exists, but it must not +silently steal or overwrite that lease. Pre-1.8.7 clients cannot participate in +the lease protocol and must not be used as concurrent writers. + +## Encryption boundary + +- Backup encryption descriptors may describe the non-secret format needed to + recognize encrypted content. +- Plaintext passwords, derived keys, operating-system credential blobs, tokens, + and recovery secrets never belong in portable metadata, support bundles, or + evidence packages. +- A key reference from one installation is machine-local unless an explicit + future portable-key mechanism says otherwise. Importing the reference must not + imply that the secret exists on the receiving machine. + +See [Encryption](wiki/Encryption.md) for the supported backup format and secret +storage behavior. + +## Emergency read-only inspection + +When VaultSync cannot open a destination normally: + +1. Stop automatic backup activity on every machine that can reach the + destination. +2. Preserve the destination as-is. Do not rename, delete, compact, or directly + edit the SQLite database or its `-wal`/`-shm` sidecars. +3. Copy the complete `.vaultsync/meta/` directory and the relevant backup + payload to separate storage before diagnosis. +4. Record the VaultSync version, platform, destination path/alias, error, and + whether another client may have been writing. +5. Use VaultSync preview or read-only recovery surfaces against a copy. Do not + make the only remaining repository copy the repair target. +6. If manual SQLite inspection is unavoidable, open the copied database + read-only and do not claim the result is a supported repair. + +The portable metadata store is an inventory and recovery aid; backup payloads +remain the source of recoverable bytes. A lost or corrupt metadata database must +not be “repaired” by deleting backup payloads. + +## Change discipline + +Any repository-format change requires all of the following in one PR: + +- a schema-version decision and forward migration; +- upgrade fixtures from every supported predecessor; +- interrupted-write and corrupt/unknown-version tests; +- portable-versus-local field classification; +- updated `DOCUMENTATION.md`, Metadata Sync guidance, and release notes; +- a clean-machine recovery exercise before release. + +The active delivery status and acceptance gates are maintained in the +[1.8.7 release contract](RELEASE_1.8.7.md). Identity, lease, and merge protocol +invariants are maintained in [Cross-machine safety](CROSS_MACHINE_SAFETY.md). diff --git a/docs/wiki/Metadata-Sync.md b/docs/wiki/Metadata-Sync.md index dd3738af..88abbaac 100644 --- a/docs/wiki/Metadata-Sync.md +++ b/docs/wiki/Metadata-Sync.md @@ -74,6 +74,13 @@ resolution, per-record writer provenance, and a repository-scoped writer lease. Until that ships, use one machine as the writer for a destination and use other machines for recovery inspection or deliberate imports. +The maintained 1.8.7 implementation status is recorded in the +[1.8.7 release contract](../RELEASE_1.8.7.md). The current and planned on-disk +layouts, compatibility rules, and emergency inspection boundary are documented +in [Repository formats](../REPOSITORY_FORMATS.md). +The writer and merge threat model is in +[Cross-machine safety](../CROSS_MACHINE_SAFETY.md). + ![Doctor, metadata-conflict, maintenance, and update controls](../images/Settings_Maintenance.png) ## Missing backup paths From 81b3865fbba9e288f0ac4cec50c2f0e46fb51ad4 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:56:59 +0200 Subject: [PATCH 07/54] Establish durable installation identity --- docs/CROSS_MACHINE_SAFETY.md | 1 + docs/RELEASE_1.8.7.md | 8 +- .../Services/InstallationIdentityService.cs | 154 ++++++++++++++++++ .../InstallationIdentityServiceTests.cs | 91 +++++++++++ 4 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/VaultSync.Core/Services/InstallationIdentityService.cs create mode 100644 tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index 0b3a5fb1..57f27805 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -122,6 +122,7 @@ revision. ## Safe rollout order 1. Land and test durable installation identity without changing repository data. + **Implemented on the 1.8.7 release branch on 2026-08-12.** 2. Add lease parsing and read-only busy diagnostics. 3. Protect every metadata writer, including tombstones and repair/migration. 4. Add the versioned schema and forward migration fixtures. diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index a345880c..2ff4c2d4 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -33,6 +33,10 @@ assumed and must be approved explicitly if the release needs one. embedded in every supported RID. - The permanent `Dev` branch was restored at the `v1.8.6` Stable commit and automatic head-branch deletion was disabled. +- The durable installation-identity provider is implemented and tested. It + creates one atomic owner-private identity, rejects malformed or linked + identity files, and remains separate from telemetry and host name. Repository + records do not consume it yet; lease integration is the next safety slice. These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -41,8 +45,8 @@ branch package. ### In progress next -1. Complete the repository and cross-machine threat model. -2. Introduce durable installation identity and repository-scoped writer safety. +1. Add repository lease parsing and read-only busy diagnostics. +2. Protect every metadata writer with durable identity and nonce ownership. 3. Make metadata imports previewable, versioned, durable, and reversible. 4. Generate the release manifest and expose complete build identity. diff --git a/src/VaultSync.Core/Services/InstallationIdentityService.cs b/src/VaultSync.Core/Services/InstallationIdentityService.cs new file mode 100644 index 00000000..17074650 --- /dev/null +++ b/src/VaultSync.Core/Services/InstallationIdentityService.cs @@ -0,0 +1,154 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace VaultSync.Core.Services; + +public interface IInstallationIdentityProvider +{ + string GetOrCreate(); +} + +/// +/// Provides the durable, machine-local identity used by repository coordination. +/// This identity is deliberately independent of telemetry and the mutable host name. +/// +public sealed class InstallationIdentityService : IInstallationIdentityProvider +{ + public const string IdentityFileName = "installation.id"; + + private static readonly ConcurrentDictionary PathGates = + new(GetPathComparer()); + + private readonly string _dataDirectory; + + public InstallationIdentityService(string? dataDirectory = null) + { + _dataDirectory = string.IsNullOrWhiteSpace(dataDirectory) + ? ResolveDefaultDataDirectory() + : Path.GetFullPath(dataDirectory); + } + + public string IdentityPath => Path.Combine(_dataDirectory, IdentityFileName); + + public string GetOrCreate() + { + object pathGate = PathGates.GetOrAdd(IdentityPath, static _ => new object()); + lock (pathGate) + { + PrivateDataPermissions.EnsureDirectory(_dataDirectory); + + if (File.Exists(IdentityPath)) + return ReadExistingIdentity(); + + return CreateIdentityAtomically(); + } + } + + private string ReadExistingIdentity() + { + FileAttributes attributes = File.GetAttributes(IdentityPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException( + $"Installation identity must be a regular private file: '{IdentityPath}'."); + } + + string serialized = File.ReadAllText(IdentityPath, Encoding.UTF8).Trim(); + if (!Guid.TryParseExact(serialized, "N", out Guid parsed) || parsed == Guid.Empty) + { + throw new InvalidDataException( + $"Installation identity is malformed and was not replaced: '{IdentityPath}'."); + } + + string canonical = parsed.ToString("N"); + if (!string.Equals(serialized, canonical, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Installation identity is not in canonical form and was not replaced: '{IdentityPath}'."); + } + + PrivateDataPermissions.RestrictFile(IdentityPath); + return canonical; + } + + private string CreateIdentityAtomically() + { + string identity = Guid.NewGuid().ToString("N"); + string temporaryPath = Path.Combine( + _dataDirectory, + $".{IdentityFileName}.{Guid.NewGuid():N}.tmp"); + + try + { + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough)) + using (var writer = new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + bufferSize: 1024, + leaveOpen: true)) + { + writer.WriteLine(identity); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + PrivateDataPermissions.RestrictFile(temporaryPath); + + try + { + File.Move(temporaryPath, IdentityPath, overwrite: false); + } + catch (IOException) when (File.Exists(IdentityPath)) + { + return ReadExistingIdentity(); + } + + PrivateDataPermissions.RestrictFile(IdentityPath); + return identity; + } + finally + { + TryDeleteTemporaryFile(temporaryPath); + } + } + + private static string ResolveDefaultDataDirectory() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrWhiteSpace(appData)) + { + throw new InvalidOperationException( + "The application data directory is unavailable; a durable installation identity cannot be created."); + } + + return Path.Combine(appData, "VaultSync"); + } + + private static void TryDeleteTemporaryFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) + { + // A failed cleanup does not invalidate a successfully persisted identity. + } + catch (UnauthorizedAccessException) + { + // A failed cleanup does not invalidate a successfully persisted identity. + } + } + + private static StringComparer GetPathComparer() => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} diff --git a/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs b/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs new file mode 100644 index 00000000..c97797b4 --- /dev/null +++ b/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using VaultSync.Core.Services; +using VaultSync.Core.Tests.TestSupport; +using Xunit; + +namespace VaultSync.Core.Tests; + +public sealed class InstallationIdentityServiceTests +{ + [Fact] + public void GetOrCreate_CreatesCanonicalDurableIdentity() + { + using var directory = new TempDirectory(); + var firstService = new InstallationIdentityService(directory.Path); + + string first = firstService.GetOrCreate(); + string second = new InstallationIdentityService(directory.Path).GetOrCreate(); + + Assert.Equal(32, first.Length); + Assert.Equal(first, first.ToLowerInvariant()); + Assert.True(Guid.TryParseExact(first, "N", out Guid parsed)); + Assert.NotEqual(Guid.Empty, parsed); + Assert.Equal(first, second); + Assert.Equal(first, File.ReadAllText(firstService.IdentityPath).Trim()); + } + + [Fact] + public async System.Threading.Tasks.Task GetOrCreate_ConcurrentCallersObserveOneIdentity() + { + using var directory = new TempDirectory(); + var service = new InstallationIdentityService(directory.Path); + + string[] identities = await Task.WhenAll( + Enumerable.Range(0, 32) + .Select(_ => Task.Run(service.GetOrCreate))); + + Assert.Single(identities.Distinct(StringComparer.Ordinal)); + Assert.Equal(identities[0], File.ReadAllText(service.IdentityPath).Trim()); + } + + [Theory] + [InlineData("")] + [InlineData("not-an-identity")] + [InlineData("00000000000000000000000000000000")] + [InlineData("ABCDEFABCDEFABCDEFABCDEFABCDEFAB")] + public void GetOrCreate_MalformedExistingIdentityFailsClosed(string value) + { + using var directory = new TempDirectory(); + var service = new InstallationIdentityService(directory.Path); + File.WriteAllText(service.IdentityPath, value); + + InvalidDataException error = Assert.Throws(service.GetOrCreate); + + Assert.Contains("was not replaced", error.Message, StringComparison.Ordinal); + Assert.Equal(value, File.ReadAllText(service.IdentityPath)); + } + + [Fact] + public void GetOrCreate_RestrictsUnixIdentityPermissions() + { + if (OperatingSystem.IsWindows()) + return; + + using var directory = new TempDirectory(); + var service = new InstallationIdentityService(directory.Path); + + service.GetOrCreate(); + + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + File.GetUnixFileMode(service.IdentityPath)); + } + + [Fact] + public void GetOrCreate_RejectsSymbolicLinkIdentity() + { + if (OperatingSystem.IsWindows()) + return; + + using var directory = new TempDirectory(); + string targetPath = Path.Combine(directory.Path, "identity-target.txt"); + File.WriteAllText(targetPath, Guid.NewGuid().ToString("N")); + var service = new InstallationIdentityService(directory.Path); + File.CreateSymbolicLink(service.IdentityPath, targetPath); + + Assert.Throws(service.GetOrCreate); + } +} From 18cdd6987bd2c7ba45b1cd6665c3f2dd2f80efa5 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:08:36 +0200 Subject: [PATCH 08/54] Add repository writer leases --- docs/CROSS_MACHINE_SAFETY.md | 10 +- docs/RELEASE_1.8.7.md | 9 +- docs/REPOSITORY_FORMATS.md | 15 +- .../Services/RepositoryLeaseService.cs | 911 ++++++++++++++++++ .../RepositoryLeaseServiceTests.cs | 202 ++++ 5 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 src/VaultSync.Core/Services/RepositoryLeaseService.cs create mode 100644 tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index 57f27805..f15c0477 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -62,7 +62,10 @@ than assuming it is the same process. ## Writer lease -The repository stores one coordination record containing: +The repository stores one coordination record in +`.vaultsync/meta/writer.lease.db`, separate from the portable metadata schema. +SQLite immediate transactions provide compare-and-swap ownership for cooperating +clients. The active record contains: | Field | Meaning | |---|---| @@ -82,6 +85,11 @@ the second client receives a busy result and may continue read-only. A lease holder renews before one third of the lease duration elapses. Release succeeds only when installation id and nonce still match the on-disk record. +The lease primitive, read-only inspection, automatic heartbeat, nonce-bound +release, conservative expiry, explicit stale takeover, and takeover evidence +were implemented on the 1.8.7 release branch on 2026-08-12. Metadata writers +are connected to this boundary in the next rollout slice. + Expiry is evidence that a lease may be stale, not permission for invisible takeover. The user must explicitly confirm takeover; the old record is preserved as diagnostic evidence before a new lease is acquired. A former owner whose diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 2ff4c2d4..9e5754b1 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -37,6 +37,11 @@ assumed and must be approved explicitly if the release needs one. creates one atomic owner-private identity, rejects malformed or linked identity files, and remains separate from telemetry and host name. Repository records do not consume it yet; lease integration is the next safety slice. +- The repository lease primitive is implemented and tested in a separate + coordination database: atomic acquisition, busy/read-only inspection, + automatic heartbeat, conservative expiry, nonce-bound release, explicit + stale takeover, and exceptional takeover evidence. Metadata writer integration + is still in progress and no UI takeover control is exposed yet. These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -45,8 +50,8 @@ branch package. ### In progress next -1. Add repository lease parsing and read-only busy diagnostics. -2. Protect every metadata writer with durable identity and nonce ownership. +1. Protect every metadata writer with durable identity and nonce ownership. +2. Surface busy/read-only status and an explicit stale-takeover decision in UI. 3. Make metadata imports previewable, versioned, durable, and reversible. 4. Generate the release manifest and expose complete build identity. diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index 72417943..bd0b5a5a 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -13,6 +13,7 @@ tests land. | Application database | Local projects, snapshots, backups, and application state | Machine-local | | Application configuration | UI, destinations, schedules, and operational preferences | Machine-local | | `/.vaultsync/meta/vaultsync.meta.db` | Portable project and backup-history metadata | Cross-machine | +| `/.vaultsync/meta/writer.lease.db` | 1.8.7 cooperating-writer coordination and exceptional takeover evidence | Repository-local coordination | | Backup payload folders/archives | Recoverable project bytes | Cross-machine when the destination is reachable | | Recovery evidence reports | Readable proof and drill summaries | Exportable, redacted | @@ -58,14 +59,22 @@ single-writer guidance are documented in ## Repository coordination — Planned for 1.8.7 -The next schema revision will add, with explicit migrations and fixtures: +The coordination database and durable installation identity are implemented on +the 1.8.7 release branch. Protection of every metadata writer is the next rollout +slice. The later portable-metadata schema revision will add, with explicit +migrations and fixtures: - durable installation identity distinct from host name; - per-record revision, base revision, writer identity, and timestamp; - field-level portability and provenance for project settings; - durable conflict-resolution records; -- a repository-scoped writer lease with owner, operation, nonce, heartbeat, - expiry, and application version. +- per-record linkage to the repository-scoped writer identity where required. + +The separate coordination database currently records one active lease with +owner, diagnostic host label, process, operation, nonce, application version, +acquisition, heartbeat, and expiry. Normal release clears the active row without +growing history. Explicit stale takeover preserves the displaced record as +diagnostic evidence. The migration must preserve every readable version-1 record. A 1.8.7 client may inspect a repository read-only while another valid lease exists, but it must not diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs new file mode 100644 index 00000000..d9861895 --- /dev/null +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -0,0 +1,911 @@ +using System.Globalization; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace VaultSync.Core.Services; + +public sealed record RepositoryLeaseRequest( + string InstallationId, + string HostLabel, + string Operation, + string AppVersion, + TimeSpan? Duration = null); + +public sealed record RepositoryLeaseSnapshot( + int ProtocolVersion, + string InstallationId, + string HostLabel, + int ProcessId, + string Operation, + string Nonce, + string AppVersion, + DateTimeOffset AcquiredUtc, + DateTimeOffset HeartbeatUtc, + DateTimeOffset ExpiresUtc); + +public sealed record RepositoryLeaseInspection( + RepositoryLeaseState State, + RepositoryLeaseSnapshot? Lease, + string Message); + +public sealed record RepositoryLeaseAcquireResult( + RepositoryLeaseAcquireStatus Status, + RepositoryLeaseInspection Inspection, + RepositoryLeaseHandle? Handle) +{ + public bool Acquired => Status == RepositoryLeaseAcquireStatus.Acquired && Handle is not null; +} + +public sealed record RepositoryLeaseEvidence( + string Nonce, + string InstallationId, + string HostLabel, + string Operation, + string AppVersion, + DateTimeOffset AcquiredUtc, + DateTimeOffset HeartbeatUtc, + DateTimeOffset ExpiresUtc, + DateTimeOffset RecordedUtc, + string Disposition); + +public enum RepositoryLeaseState +{ + Available, + Active, + Stale, + Invalid, + Unavailable +} + +public enum RepositoryLeaseAcquireStatus +{ + Acquired, + Busy, + Stale, + Invalid, + Unavailable +} + +/// +/// Coordinates cooperating VaultSync writers through a repository-local SQLite +/// lease. The coordination database is separate from portable metadata schema +/// evolution so read-only inspection and lease rollout do not rewrite metadata. +/// +public sealed class RepositoryLeaseService +{ + public const int CurrentProtocolVersion = 1; + public const string CoordinationDatabaseName = "writer.lease.db"; + + private const int SingletonLeaseId = 1; + private static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MinimumLeaseDuration = TimeSpan.FromSeconds(15); + private static readonly TimeSpan MaximumLeaseDuration = TimeSpan.FromMinutes(30); + private static readonly TimeSpan DefaultClockSkewTolerance = TimeSpan.FromMinutes(2); + + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _clockSkewTolerance; + + public RepositoryLeaseService( + TimeProvider? timeProvider = null, + TimeSpan? clockSkewTolerance = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _clockSkewTolerance = clockSkewTolerance ?? DefaultClockSkewTolerance; + if (_clockSkewTolerance < TimeSpan.Zero || _clockSkewTolerance > TimeSpan.FromMinutes(10)) + throw new ArgumentOutOfRangeException(nameof(clockSkewTolerance)); + } + + public string GetDatabasePath(string rootPath) => + Path.Combine(GetMetadataDirectory(rootPath), CoordinationDatabaseName); + + public RepositoryLeaseInspection Inspect(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + return InvalidInspection("Repository root is empty."); + + string databasePath; + try + { + databasePath = GetDatabasePath(rootPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return InvalidInspection("Repository root is invalid."); + } + + if (!File.Exists(databasePath)) + return AvailableInspection(); + + if (IsLinkedFile(databasePath)) + return InvalidInspection("Repository coordination database must be a regular file."); + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: true); + if (!HasLeaseTable(connection)) + return InvalidInspection("Repository coordination database has no supported lease table."); + + return InspectInConnection(connection, transaction: null, _timeProvider.GetUtcNow()); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return new RepositoryLeaseInspection( + RepositoryLeaseState.Unavailable, + null, + $"Repository coordination state is unavailable: {ex.Message}"); + } + } + + public RepositoryLeaseAcquireResult TryAcquire(string rootPath, RepositoryLeaseRequest request) + { + string? validationError = ValidateRequest(request); + if (validationError is not null) + return FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, InvalidInspection(validationError)); + + string databasePath; + try + { + databasePath = GetDatabasePath(rootPath); + Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return FailedAcquire( + RepositoryLeaseAcquireStatus.Unavailable, + new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, ex.Message)); + } + + if (File.Exists(databasePath) && IsLinkedFile(databasePath)) + return FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, InvalidInspection("Repository coordination database must be a regular file.")); + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: false); + EnsureSchema(connection); + using SqliteTransaction transaction = connection.BeginTransaction(deferred: false); + DateTimeOffset now = _timeProvider.GetUtcNow(); + RepositoryLeaseInspection current = InspectInConnection(connection, transaction, now); + if (current.State != RepositoryLeaseState.Available) + { + transaction.Rollback(); + return current.State switch + { + RepositoryLeaseState.Active => FailedAcquire(RepositoryLeaseAcquireStatus.Busy, current), + RepositoryLeaseState.Stale => FailedAcquire(RepositoryLeaseAcquireStatus.Stale, current), + RepositoryLeaseState.Invalid => FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, current), + _ => FailedAcquire(RepositoryLeaseAcquireStatus.Unavailable, current) + }; + } + + RepositoryLeaseSnapshot lease = CreateSnapshot(request, now); + InsertLease(connection, transaction, lease); + transaction.Commit(); + return AcquiredResult(rootPath, lease, ResolveDuration(request.Duration)); + } + catch (Exception ex) when (IsStorageException(ex)) + { + RepositoryLeaseInspection current = Inspect(rootPath); + if (current.State == RepositoryLeaseState.Active) + return FailedAcquire(RepositoryLeaseAcquireStatus.Busy, current); + if (current.State == RepositoryLeaseState.Stale) + return FailedAcquire(RepositoryLeaseAcquireStatus.Stale, current); + + return FailedAcquire( + RepositoryLeaseAcquireStatus.Unavailable, + new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, ex.Message)); + } + } + + public RepositoryLeaseAcquireResult TakeOverStale( + string rootPath, + string expectedNonce, + RepositoryLeaseRequest request) + { + string? validationError = ValidateRequest(request); + if (validationError is not null || !IsCanonicalId(expectedNonce)) + { + return FailedAcquire( + RepositoryLeaseAcquireStatus.Invalid, + InvalidInspection(validationError ?? "Expected lease nonce is invalid.")); + } + + string databasePath = GetDatabasePath(rootPath); + if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) + return FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, InvalidInspection("No valid stale lease is available for takeover.")); + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: false); + EnsureSchema(connection); + using SqliteTransaction transaction = connection.BeginTransaction(deferred: false); + DateTimeOffset now = _timeProvider.GetUtcNow(); + RepositoryLeaseInspection current = InspectInConnection(connection, transaction, now); + if (current.State != RepositoryLeaseState.Stale || + current.Lease is null || + !string.Equals(current.Lease.Nonce, expectedNonce, StringComparison.Ordinal)) + { + transaction.Rollback(); + RepositoryLeaseAcquireStatus status = current.State == RepositoryLeaseState.Active + ? RepositoryLeaseAcquireStatus.Busy + : RepositoryLeaseAcquireStatus.Invalid; + return FailedAcquire(status, current); + } + + RecordEvidence(connection, transaction, current.Lease, now, "stale-takeover"); + RepositoryLeaseSnapshot replacement = CreateSnapshot(request, now); + int changed = ReplaceLease(connection, transaction, expectedNonce, replacement); + if (changed != 1) + { + transaction.Rollback(); + return FailedAcquire(RepositoryLeaseAcquireStatus.Busy, Inspect(rootPath)); + } + + transaction.Commit(); + return AcquiredResult(rootPath, replacement, ResolveDuration(request.Duration)); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return FailedAcquire( + RepositoryLeaseAcquireStatus.Unavailable, + new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, ex.Message)); + } + } + + public IReadOnlyList ListEvidence(string rootPath) + { + string databasePath = GetDatabasePath(rootPath); + if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) + return []; + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: true); + if (!HasEvidenceTable(connection)) + return []; + + return connection.Query( + """ + SELECT + nonce as Nonce, + installation_id as InstallationId, + host_label as HostLabel, + operation as Operation, + app_version as AppVersion, + acquired_utc as AcquiredUtc, + heartbeat_utc as HeartbeatUtc, + expires_utc as ExpiresUtc, + recorded_utc as RecordedUtc, + disposition as Disposition + FROM lease_evidence + ORDER BY evidence_id; + """) + .Select(ToEvidence) + .ToList(); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return []; + } + } + + internal RepositoryLeaseSnapshot? TryRenew( + string rootPath, + string installationId, + string nonce, + TimeSpan duration) + { + return MutateOwnedLease( + rootPath, + installationId, + nonce, + (connection, transaction, current, now) => + { + if (current.ExpiresUtc <= now) + return null; + + RepositoryLeaseSnapshot renewed = current with + { + HeartbeatUtc = now, + ExpiresUtc = now.Add(duration) + }; + int changed = UpdateHeartbeat(connection, transaction, renewed); + return changed == 1 ? renewed : null; + }); + } + + internal bool IsOwner(string rootPath, string installationId, string nonce) + { + RepositoryLeaseInspection inspection = Inspect(rootPath); + return inspection.State == RepositoryLeaseState.Active && + inspection.Lease is not null && + string.Equals(inspection.Lease.InstallationId, installationId, StringComparison.Ordinal) && + string.Equals(inspection.Lease.Nonce, nonce, StringComparison.Ordinal); + } + + internal bool TryRelease(string rootPath, string installationId, string nonce) + { + string databasePath = GetDatabasePath(rootPath); + if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) + return false; + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: false); + using SqliteTransaction transaction = connection.BeginTransaction(deferred: false); + LeaseRow? row = QueryLease(connection, transaction); + if (!TryParse(row, out RepositoryLeaseSnapshot? current, out _) || + current is null || + !string.Equals(current.InstallationId, installationId, StringComparison.Ordinal) || + !string.Equals(current.Nonce, nonce, StringComparison.Ordinal)) + { + transaction.Rollback(); + return false; + } + + int changed = connection.Execute( + "DELETE FROM repository_lease WHERE lease_id = @LeaseId AND installation_id = @InstallationId AND nonce = @Nonce;", + new + { + LeaseId = SingletonLeaseId, + InstallationId = installationId, + Nonce = nonce + }, + transaction); + transaction.Commit(); + return changed == 1; + } + catch (Exception ex) when (IsStorageException(ex)) + { + return false; + } + } + + private RepositoryLeaseSnapshot? MutateOwnedLease( + string rootPath, + string installationId, + string nonce, + Func mutation) + { + string databasePath = GetDatabasePath(rootPath); + if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) + return null; + + try + { + using SqliteConnection connection = OpenConnection(databasePath, readOnly: false); + using SqliteTransaction transaction = connection.BeginTransaction(deferred: false); + LeaseRow? row = QueryLease(connection, transaction); + if (!TryParse(row, out RepositoryLeaseSnapshot? current, out _) || + current is null || + !string.Equals(current.InstallationId, installationId, StringComparison.Ordinal) || + !string.Equals(current.Nonce, nonce, StringComparison.Ordinal)) + { + transaction.Rollback(); + return null; + } + + RepositoryLeaseSnapshot? updated = mutation(connection, transaction, current, _timeProvider.GetUtcNow()); + if (updated is null) + { + transaction.Rollback(); + return null; + } + + transaction.Commit(); + return updated; + } + catch (Exception ex) when (IsStorageException(ex)) + { + return null; + } + } + + private RepositoryLeaseAcquireResult AcquiredResult( + string rootPath, + RepositoryLeaseSnapshot lease, + TimeSpan duration) + { + var inspection = new RepositoryLeaseInspection(RepositoryLeaseState.Active, lease, "Repository write lease acquired."); + return new RepositoryLeaseAcquireResult( + RepositoryLeaseAcquireStatus.Acquired, + inspection, + new RepositoryLeaseHandle(this, rootPath, lease, duration)); + } + + internal ITimer CreateHeartbeatTimer(TimerCallback callback, object state, TimeSpan interval) => + _timeProvider.CreateTimer(callback, state, interval, interval); + + private RepositoryLeaseSnapshot CreateSnapshot(RepositoryLeaseRequest request, DateTimeOffset now) + { + TimeSpan duration = ResolveDuration(request.Duration); + return new RepositoryLeaseSnapshot( + CurrentProtocolVersion, + request.InstallationId, + request.HostLabel.Trim(), + Environment.ProcessId, + request.Operation.Trim(), + Guid.NewGuid().ToString("N"), + request.AppVersion.Trim(), + now, + now, + now.Add(duration)); + } + + private RepositoryLeaseInspection InspectInConnection( + SqliteConnection connection, + SqliteTransaction? transaction, + DateTimeOffset now) + { + LeaseRow? row = QueryLease(connection, transaction); + if (row is null) + return AvailableInspection(); + + if (!TryParse(row, out RepositoryLeaseSnapshot? lease, out string error) || lease is null) + return InvalidInspection(error); + + bool stale = lease.ExpiresUtc.Add(_clockSkewTolerance) <= now; + return stale + ? new RepositoryLeaseInspection(RepositoryLeaseState.Stale, lease, "Repository write lease is stale and requires explicit takeover.") + : new RepositoryLeaseInspection(RepositoryLeaseState.Active, lease, "Repository is busy; read-only inspection remains available."); + } + + private static LeaseRow? QueryLease(SqliteConnection connection, SqliteTransaction? transaction) => + connection.QuerySingleOrDefault( + """ + SELECT + protocol_version as ProtocolVersion, + installation_id as InstallationId, + host_label as HostLabel, + process_id as ProcessId, + operation as Operation, + nonce as Nonce, + app_version as AppVersion, + acquired_utc as AcquiredUtc, + heartbeat_utc as HeartbeatUtc, + expires_utc as ExpiresUtc + FROM repository_lease + WHERE lease_id = @LeaseId; + """, + new + { + LeaseId = SingletonLeaseId + }, + transaction); + + private static void InsertLease(SqliteConnection connection, SqliteTransaction transaction, RepositoryLeaseSnapshot lease) + { + connection.Execute( + """ + INSERT INTO repository_lease( + lease_id, protocol_version, installation_id, host_label, process_id, + operation, nonce, app_version, acquired_utc, heartbeat_utc, expires_utc) + VALUES( + @LeaseId, @ProtocolVersion, @InstallationId, @HostLabel, @ProcessId, + @Operation, @Nonce, @AppVersion, @AcquiredUtc, @HeartbeatUtc, @ExpiresUtc); + """, + ToParameters(lease), + transaction); + } + + private static int ReplaceLease( + SqliteConnection connection, + SqliteTransaction transaction, + string expectedNonce, + RepositoryLeaseSnapshot lease) + { + var parameters = ToParameters(lease); + return connection.Execute( + """ + UPDATE repository_lease SET + protocol_version = @ProtocolVersion, + installation_id = @InstallationId, + host_label = @HostLabel, + process_id = @ProcessId, + operation = @Operation, + nonce = @Nonce, + app_version = @AppVersion, + acquired_utc = @AcquiredUtc, + heartbeat_utc = @HeartbeatUtc, + expires_utc = @ExpiresUtc + WHERE lease_id = @LeaseId AND nonce = @ExpectedNonce; + """, + new + { + parameters.LeaseId, + parameters.ProtocolVersion, + parameters.InstallationId, + parameters.HostLabel, + parameters.ProcessId, + parameters.Operation, + parameters.Nonce, + parameters.AppVersion, + parameters.AcquiredUtc, + parameters.HeartbeatUtc, + parameters.ExpiresUtc, + ExpectedNonce = expectedNonce + }, + transaction); + } + + private static int UpdateHeartbeat( + SqliteConnection connection, + SqliteTransaction transaction, + RepositoryLeaseSnapshot lease) => + connection.Execute( + """ + UPDATE repository_lease + SET heartbeat_utc = @HeartbeatUtc, expires_utc = @ExpiresUtc + WHERE lease_id = @LeaseId AND installation_id = @InstallationId AND nonce = @Nonce; + """, + new + { + LeaseId = SingletonLeaseId, + lease.InstallationId, + lease.Nonce, + HeartbeatUtc = FormatUtc(lease.HeartbeatUtc), + ExpiresUtc = FormatUtc(lease.ExpiresUtc) + }, + transaction); + + private static void RecordEvidence( + SqliteConnection connection, + SqliteTransaction transaction, + RepositoryLeaseSnapshot lease, + DateTimeOffset recordedUtc, + string disposition) + { + connection.Execute( + """ + INSERT INTO lease_evidence( + nonce, installation_id, host_label, operation, app_version, + acquired_utc, heartbeat_utc, expires_utc, recorded_utc, disposition) + VALUES( + @Nonce, @InstallationId, @HostLabel, @Operation, @AppVersion, + @AcquiredUtc, @HeartbeatUtc, @ExpiresUtc, @RecordedUtc, @Disposition); + """, + new + { + lease.Nonce, + lease.InstallationId, + lease.HostLabel, + lease.Operation, + lease.AppVersion, + AcquiredUtc = FormatUtc(lease.AcquiredUtc), + HeartbeatUtc = FormatUtc(lease.HeartbeatUtc), + ExpiresUtc = FormatUtc(lease.ExpiresUtc), + RecordedUtc = FormatUtc(recordedUtc), + Disposition = disposition + }, + transaction); + } + + private static LeaseParameters ToParameters(RepositoryLeaseSnapshot lease) => new() + { + LeaseId = SingletonLeaseId, + ProtocolVersion = lease.ProtocolVersion, + InstallationId = lease.InstallationId, + HostLabel = lease.HostLabel, + ProcessId = lease.ProcessId, + Operation = lease.Operation, + Nonce = lease.Nonce, + AppVersion = lease.AppVersion, + AcquiredUtc = FormatUtc(lease.AcquiredUtc), + HeartbeatUtc = FormatUtc(lease.HeartbeatUtc), + ExpiresUtc = FormatUtc(lease.ExpiresUtc) + }; + + private static bool TryParse( + LeaseRow? row, + out RepositoryLeaseSnapshot? lease, + out string error) + { + lease = null; + if (row is null) + { + error = string.Empty; + return false; + } + + if (row.ProtocolVersion != CurrentProtocolVersion || + !IsCanonicalId(row.InstallationId) || + !IsCanonicalId(row.Nonce) || + string.IsNullOrWhiteSpace(row.Operation) || + string.IsNullOrWhiteSpace(row.AppVersion) || + !TryParseUtc(row.AcquiredUtc, out DateTimeOffset acquiredUtc) || + !TryParseUtc(row.HeartbeatUtc, out DateTimeOffset heartbeatUtc) || + !TryParseUtc(row.ExpiresUtc, out DateTimeOffset expiresUtc) || + heartbeatUtc < acquiredUtc || + expiresUtc <= heartbeatUtc) + { + error = "Repository write lease is malformed or uses an unsupported protocol."; + return false; + } + + lease = new RepositoryLeaseSnapshot( + row.ProtocolVersion, + row.InstallationId, + row.HostLabel ?? string.Empty, + row.ProcessId, + row.Operation, + row.Nonce, + row.AppVersion, + acquiredUtc, + heartbeatUtc, + expiresUtc); + error = string.Empty; + return true; + } + + private static RepositoryLeaseEvidence ToEvidence(LeaseEvidenceRow row) => new( + row.Nonce, + row.InstallationId, + row.HostLabel ?? string.Empty, + row.Operation, + row.AppVersion, + ParseUtc(row.AcquiredUtc), + ParseUtc(row.HeartbeatUtc), + ParseUtc(row.ExpiresUtc), + ParseUtc(row.RecordedUtc), + row.Disposition); + + private static string? ValidateRequest(RepositoryLeaseRequest request) + { + if (!IsCanonicalId(request.InstallationId)) + return "Installation identity must be a canonical non-empty identifier."; + if (string.IsNullOrWhiteSpace(request.Operation) || request.Operation.Trim().Length > 100) + return "Repository operation is required and must not exceed 100 characters."; + if (string.IsNullOrWhiteSpace(request.AppVersion) || request.AppVersion.Trim().Length > 64) + return "Application version is required and must not exceed 64 characters."; + if (request.HostLabel?.Trim().Length > 200) + return "Host label must not exceed 200 characters."; + + try + { + _ = ResolveDuration(request.Duration); + } + catch (ArgumentOutOfRangeException) + { + return "Lease duration is outside the supported range."; + } + + return null; + } + + private static TimeSpan ResolveDuration(TimeSpan? requested) + { + TimeSpan duration = requested ?? DefaultLeaseDuration; + if (duration < MinimumLeaseDuration || duration > MaximumLeaseDuration) + throw new ArgumentOutOfRangeException(nameof(requested)); + return duration; + } + + private static bool IsCanonicalId(string? value) => + value is not null && + Guid.TryParseExact(value, "N", out Guid parsed) && + parsed != Guid.Empty && + string.Equals(value, parsed.ToString("N"), StringComparison.Ordinal); + + private static string GetMetadataDirectory(string rootPath) => + Path.Combine(Path.GetFullPath(rootPath), ".vaultsync", "meta"); + + private static SqliteConnection OpenConnection(string databasePath, bool readOnly) + { + var builder = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Private, + DefaultTimeout = 5 + }; + var connection = new SqliteConnection(builder.ToString()); + connection.Open(); + connection.Execute("PRAGMA busy_timeout = 5000;"); + return connection; + } + + private static void EnsureSchema(SqliteConnection connection) => connection.Execute( + """ + CREATE TABLE IF NOT EXISTS repository_lease( + lease_id INTEGER PRIMARY KEY CHECK(lease_id = 1), + protocol_version INTEGER NOT NULL, + installation_id TEXT NOT NULL, + host_label TEXT NOT NULL, + process_id INTEGER NOT NULL, + operation TEXT NOT NULL, + nonce TEXT NOT NULL, + app_version TEXT NOT NULL, + acquired_utc TEXT NOT NULL, + heartbeat_utc TEXT NOT NULL, + expires_utc TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lease_evidence( + evidence_id INTEGER PRIMARY KEY AUTOINCREMENT, + nonce TEXT NOT NULL, + installation_id TEXT NOT NULL, + host_label TEXT NOT NULL, + operation TEXT NOT NULL, + app_version TEXT NOT NULL, + acquired_utc TEXT NOT NULL, + heartbeat_utc TEXT NOT NULL, + expires_utc TEXT NOT NULL, + recorded_utc TEXT NOT NULL, + disposition TEXT NOT NULL + ); + """); + + private static bool HasLeaseTable(SqliteConnection connection) => + connection.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'repository_lease';") == 1; + + private static bool HasEvidenceTable(SqliteConnection connection) => + connection.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'lease_evidence';") == 1; + + private static bool IsLinkedFile(string path) + { + try + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + catch (Exception ex) when (IsStorageException(ex)) + { + return true; + } + } + + private static bool IsStorageException(Exception ex) => + ex is IOException or UnauthorizedAccessException or SqliteException or ArgumentException or NotSupportedException or PathTooLongException; + + private static string FormatUtc(DateTimeOffset value) => + value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + + private static bool TryParseUtc(string? value, out DateTimeOffset parsed) => + DateTimeOffset.TryParseExact( + value, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out parsed); + + private static DateTimeOffset ParseUtc(string value) => + TryParseUtc(value, out DateTimeOffset parsed) + ? parsed + : throw new InvalidDataException("Repository lease evidence timestamp is malformed."); + + private static RepositoryLeaseInspection AvailableInspection() => + new(RepositoryLeaseState.Available, null, "Repository has no active writer."); + + private static RepositoryLeaseInspection InvalidInspection(string message) => + new(RepositoryLeaseState.Invalid, null, message); + + private static RepositoryLeaseAcquireResult FailedAcquire( + RepositoryLeaseAcquireStatus status, + RepositoryLeaseInspection inspection) => + new(status, inspection, null); + + private sealed class LeaseRow + { + public int ProtocolVersion + { + get; set; + } + public string InstallationId { get; set; } = string.Empty; + public string? HostLabel + { + get; set; + } + public int ProcessId + { + get; set; + } + public string Operation { get; set; } = string.Empty; + public string Nonce { get; set; } = string.Empty; + public string AppVersion { get; set; } = string.Empty; + public string AcquiredUtc { get; set; } = string.Empty; + public string HeartbeatUtc { get; set; } = string.Empty; + public string ExpiresUtc { get; set; } = string.Empty; + } + + private sealed class LeaseParameters + { + public int LeaseId + { + get; set; + } + public int ProtocolVersion + { + get; set; + } + public string InstallationId { get; set; } = string.Empty; + public string HostLabel { get; set; } = string.Empty; + public int ProcessId + { + get; set; + } + public string Operation { get; set; } = string.Empty; + public string Nonce { get; set; } = string.Empty; + public string AppVersion { get; set; } = string.Empty; + public string AcquiredUtc { get; set; } = string.Empty; + public string HeartbeatUtc { get; set; } = string.Empty; + public string ExpiresUtc { get; set; } = string.Empty; + } + + private sealed class LeaseEvidenceRow + { + public string Nonce { get; set; } = string.Empty; + public string InstallationId { get; set; } = string.Empty; + public string? HostLabel + { + get; set; + } + public string Operation { get; set; } = string.Empty; + public string AppVersion { get; set; } = string.Empty; + public string AcquiredUtc { get; set; } = string.Empty; + public string HeartbeatUtc { get; set; } = string.Empty; + public string ExpiresUtc { get; set; } = string.Empty; + public string RecordedUtc { get; set; } = string.Empty; + public string Disposition { get; set; } = string.Empty; + } +} + +public sealed class RepositoryLeaseHandle : IDisposable +{ + private readonly RepositoryLeaseService _service; + private readonly string _rootPath; + private readonly TimeSpan _duration; + private readonly ITimer _heartbeatTimer; + private int _disposed; + + internal RepositoryLeaseHandle( + RepositoryLeaseService service, + string rootPath, + RepositoryLeaseSnapshot lease, + TimeSpan duration) + { + _service = service; + _rootPath = rootPath; + Lease = lease; + _duration = duration; + TimeSpan heartbeatInterval = TimeSpan.FromTicks(Math.Max(1, duration.Ticks / 3)); + _heartbeatTimer = _service.CreateHeartbeatTimer( + static state => ((RepositoryLeaseHandle)state!).Renew(), + this, + heartbeatInterval); + } + + public RepositoryLeaseSnapshot Lease + { + get; private set; + } + + public bool IsOwner => + Volatile.Read(ref _disposed) == 0 && + _service.IsOwner(_rootPath, Lease.InstallationId, Lease.Nonce); + + public bool Renew() + { + if (Volatile.Read(ref _disposed) != 0) + return false; + + RepositoryLeaseSnapshot? renewed = _service.TryRenew( + _rootPath, + Lease.InstallationId, + Lease.Nonce, + _duration); + if (renewed is null) + return false; + + Lease = renewed; + return true; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + _heartbeatTimer.Dispose(); + _service.TryRelease(_rootPath, Lease.InstallationId, Lease.Nonce); + } +} diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs new file mode 100644 index 00000000..ccd7807f --- /dev/null +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -0,0 +1,202 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Data.Sqlite; +using VaultSync.Core.Services; +using VaultSync.Core.Tests.TestSupport; +using Xunit; + +namespace VaultSync.Core.Tests; + +public sealed class RepositoryLeaseServiceTests +{ + [Fact] + public void Inspect_MissingCoordinationDatabaseIsAvailableAndReadOnly() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + + RepositoryLeaseInspection inspection = service.Inspect(root.Path); + + Assert.Equal(RepositoryLeaseState.Available, inspection.State); + Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + } + + [Fact] + public void TryAcquire_SecondWriterIsBusyAndReadOnlyInspectionRemainsAvailable() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + RepositoryLeaseRequest firstRequest = CreateRequest("metadata-export"); + RepositoryLeaseRequest secondRequest = CreateRequest("metadata-export"); + + using RepositoryLeaseHandle first = AssertAcquired(service.TryAcquire(root.Path, firstRequest)); + RepositoryLeaseAcquireResult second = service.TryAcquire(root.Path, secondRequest); + RepositoryLeaseInspection inspection = service.Inspect(root.Path); + + Assert.Equal(RepositoryLeaseAcquireStatus.Busy, second.Status); + Assert.Equal(RepositoryLeaseState.Active, inspection.State); + Assert.Equal(first.Lease.Nonce, inspection.Lease?.Nonce); + Assert.True(first.IsOwner); + } + + [Fact] + public async System.Threading.Tasks.Task TryAcquire_ConcurrentWritersProduceExactlyOneOwner() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + + RepositoryLeaseAcquireResult[] results = await Task.WhenAll( + Enumerable.Range(0, 16) + .Select(_ => Task.Run(() => service.TryAcquire(root.Path, CreateRequest("concurrent-export"))))); + + RepositoryLeaseAcquireResult acquired = Assert.Single(results, result => result.Acquired); + Assert.All( + results.Where(result => !ReferenceEquals(result, acquired)), + result => Assert.Equal(RepositoryLeaseAcquireStatus.Busy, result.Status)); + acquired.Handle!.Dispose(); + } + + [Fact] + public void Renew_ExtendsOwnedLeaseAndDisposeReleasesIt() + { + using var root = new TempDirectory(); + var clock = new ManualTimeProvider(new DateTimeOffset(2026, 8, 12, 12, 0, 0, TimeSpan.Zero)); + var service = new RepositoryLeaseService(clock, TimeSpan.Zero); + RepositoryLeaseAcquireResult acquired = service.TryAcquire(root.Path, CreateRequest("long-export")); + RepositoryLeaseHandle handle = AssertAcquired(acquired); + DateTimeOffset firstExpiry = handle.Lease.ExpiresUtc; + + clock.Advance(TimeSpan.FromMinutes(1)); + Assert.True(handle.Renew()); + Assert.True(handle.Lease.ExpiresUtc > firstExpiry); + + handle.Dispose(); + + Assert.Equal(RepositoryLeaseState.Available, service.Inspect(root.Path).State); + Assert.Empty(service.ListEvidence(root.Path)); + } + + [Fact] + public void StaleLeaseRequiresExplicitNonceBoundTakeoverAndPreservesEvidence() + { + using var root = new TempDirectory(); + var clock = new ManualTimeProvider(new DateTimeOffset(2026, 8, 12, 12, 0, 0, TimeSpan.Zero)); + var service = new RepositoryLeaseService(clock, TimeSpan.Zero); + RepositoryLeaseHandle oldHandle = AssertAcquired(service.TryAcquire(root.Path, CreateRequest("metadata-export"))); + + clock.Advance(TimeSpan.FromMinutes(6)); + RepositoryLeaseAcquireResult ordinaryAcquire = service.TryAcquire(root.Path, CreateRequest("metadata-export")); + RepositoryLeaseAcquireResult wrongTakeover = service.TakeOverStale( + root.Path, + Guid.NewGuid().ToString("N"), + CreateRequest("stale-takeover")); + RepositoryLeaseAcquireResult takeover = service.TakeOverStale( + root.Path, + oldHandle.Lease.Nonce, + CreateRequest("stale-takeover")); + using RepositoryLeaseHandle replacement = AssertAcquired(takeover); + + Assert.Equal(RepositoryLeaseAcquireStatus.Stale, ordinaryAcquire.Status); + Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, wrongTakeover.Status); + Assert.False(oldHandle.Renew()); + oldHandle.Dispose(); + Assert.True(replacement.IsOwner); + RepositoryLeaseEvidence evidence = Assert.Single(service.ListEvidence(root.Path)); + Assert.Equal("stale-takeover", evidence.Disposition); + Assert.Equal(ordinaryAcquire.Inspection.Lease?.Nonce, evidence.Nonce); + } + + [Fact] + public void TryAcquire_SameInstallationWithAnotherNonceIsStillBusy() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + string installationId = Guid.NewGuid().ToString("N"); + RepositoryLeaseRequest firstRequest = CreateRequest("first", installationId); + RepositoryLeaseRequest cloneRequest = CreateRequest("clone", installationId); + + using RepositoryLeaseHandle first = AssertAcquired(service.TryAcquire(root.Path, firstRequest)); + RepositoryLeaseAcquireResult clone = service.TryAcquire(root.Path, cloneRequest); + + Assert.Equal(RepositoryLeaseAcquireStatus.Busy, clone.Status); + Assert.Equal(installationId, clone.Inspection.Lease?.InstallationId); + } + + [Theory] + [InlineData("machine-name")] + [InlineData("00000000000000000000000000000000")] + [InlineData("ABCDEFABCDEFABCDEFABCDEFABCDEFAB")] + public void TryAcquire_InvalidInstallationIdentityFailsWithoutCreatingDatabase(string identity) + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + + RepositoryLeaseAcquireResult result = service.TryAcquire( + root.Path, + CreateRequest("metadata-export", identity)); + + Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, result.Status); + Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + } + + [Fact] + public void Inspect_MalformedLeaseFailsClosed() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + using RepositoryLeaseHandle handle = AssertAcquired(service.TryAcquire(root.Path, CreateRequest("metadata-export"))); + handle.Dispose(); + + using (var connection = new SqliteConnection($"Data Source={service.GetDatabasePath(root.Path)}")) + { + connection.Open(); + connection.Execute( + """ + INSERT INTO repository_lease( + lease_id, protocol_version, installation_id, host_label, process_id, + operation, nonce, app_version, acquired_utc, heartbeat_utc, expires_utc) + VALUES(1, 999, 'bad', '', 1, 'write', 'bad', '1.8.7', 'bad', 'bad', 'bad'); + """); + } + + RepositoryLeaseInspection inspection = service.Inspect(root.Path); + RepositoryLeaseAcquireResult acquire = service.TryAcquire(root.Path, CreateRequest("metadata-export")); + + Assert.Equal(RepositoryLeaseState.Invalid, inspection.State); + Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, acquire.Status); + } + + private static RepositoryLeaseRequest CreateRequest(string operation, string installationId = null) => + new( + installationId ?? Guid.NewGuid().ToString("N"), + "Test host", + operation, + "1.8.7", + TimeSpan.FromMinutes(5)); + + private static RepositoryLeaseHandle AssertAcquired(RepositoryLeaseAcquireResult result) + { + Assert.Equal(RepositoryLeaseAcquireStatus.Acquired, result.Status); + return Assert.IsType(result.Handle); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public ManualTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + } +} From 089a1b4ccd79d7102cf5afd63a817931d9b9276b Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:21:06 +0200 Subject: [PATCH 09/54] Protect cross-machine metadata writers --- ROADMAP.md | 7 + docs/CROSS_MACHINE_SAFETY.md | 10 +- docs/RELEASE_1.8.7.md | 23 +- docs/REPOSITORY_FORMATS.md | 14 +- docs/wiki/Metadata-Sync.md | 6 + .../Services/MetadataSyncService.cs | 459 +++++++++++++++--- .../Services/RepositoryLeaseService.cs | 31 +- .../AppViewModel.ConfigurationOps.cs | 6 +- .../ViewModels/AppViewModel.RuntimeOps.cs | 7 +- .../ViewModels/AppViewModel.StartupOps.cs | 4 +- src/VaultSync.UI/ViewModels/AppViewModel.cs | 1 + .../VaultSync.Core.Tests/MetadataSyncTests.cs | 234 +++++++++ .../RepositoryLeaseServiceTests.cs | 15 + 13 files changed, 722 insertions(+), 95 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a666e90..cec032bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -482,6 +482,13 @@ that exists only on the release branch or remains planned. - Acceptance: imports do not silently apply machine-local key references or destructive tombstones, every changed portable field appears in preview, writer attribution is record-specific, and resolved conflicts stay resolved. +- [x] `BUG-18102` `P0` Prevent deferred metadata replay from overwriting a + destination that changed while it was unavailable. + - Acceptance: deferred stores are lease-protected, flush at most once into an + empty metadata destination, and remain preserved for merge review when the + destination already contains metadata. + - Completed on the 1.8.7 release branch on 2026-08-12 together with durable + writer protection for every existing metadata export and tombstone path. ### Delivery sequence diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index f15c0477..ed50aa8e 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -87,8 +87,11 @@ only when installation id and nonce still match the on-disk record. The lease primitive, read-only inspection, automatic heartbeat, nonce-bound release, conservative expiry, explicit stale takeover, and takeover evidence -were implemented on the 1.8.7 release branch on 2026-08-12. Metadata writers -are connected to this boundary in the next rollout slice. +were implemented on the 1.8.7 release branch on 2026-08-12. Backup/history, +project-settings, project/snapshot/backup tombstone, deferred, and deferred-flush +writers now require lease ownership and verify their nonce again immediately +before changing metadata. Import and preview remain readable while a writer is +active; optional source-side tombstone repair is suppressed in that state. Expiry is evidence that a lease may be stale, not permission for invisible takeover. The user must explicitly confirm takeover; the old record is preserved @@ -132,7 +135,10 @@ revision. 1. Land and test durable installation identity without changing repository data. **Implemented on the 1.8.7 release branch on 2026-08-12.** 2. Add lease parsing and read-only busy diagnostics. + **Implemented on the 1.8.7 release branch on 2026-08-12.** 3. Protect every metadata writer, including tombstones and repair/migration. + **Implemented for the existing version-1 writers on 2026-08-12; every future + migration or repair writer must enter through the same boundary.** 4. Add the versioned schema and forward migration fixtures. 5. Produce merge plans without applying them. 6. Add explicit apply, durable resolution, and bounded undo. diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 9e5754b1..1209c1ea 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -35,13 +35,19 @@ assumed and must be approved explicitly if the release needs one. automatic head-branch deletion was disabled. - The durable installation-identity provider is implemented and tested. It creates one atomic owner-private identity, rejects malformed or linked - identity files, and remains separate from telemetry and host name. Repository - records do not consume it yet; lease integration is the next safety slice. + identity files, and remains separate from telemetry and host name. Production + metadata writers now use it as their lease owner while retaining host name as + a diagnostic label only. - The repository lease primitive is implemented and tested in a separate coordination database: atomic acquisition, busy/read-only inspection, automatic heartbeat, conservative expiry, nonce-bound release, explicit - stale takeover, and exceptional takeover evidence. Metadata writer integration - is still in progress and no UI takeover control is exposed yet. + stale takeover, and exceptional takeover evidence. +- Every existing portable-metadata writer now requires lease ownership: + project settings, backup/history exports, all tombstone paths, deferred writes, + and deferred flushing. Import and preview remain readable while busy and + suppress optional source writes. Deferred stores flush only into an empty + destination; divergent destination metadata is preserved for merge review + instead of being overwritten. No UI takeover control is exposed yet. These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -50,10 +56,11 @@ branch package. ### In progress next -1. Protect every metadata writer with durable identity and nonce ownership. -2. Surface busy/read-only status and an explicit stale-takeover decision in UI. -3. Make metadata imports previewable, versioned, durable, and reversible. -4. Generate the release manifest and expose complete build identity. +1. Surface repository status and an explicit stale-takeover decision in UI. +2. Make metadata imports previewable, versioned, durable, and reversible. +3. Generate the release manifest and expose complete build identity. +4. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB + behavior before enabling supported concurrent-machine workflows. ### Still planned diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index bd0b5a5a..7b93997d 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -59,10 +59,9 @@ single-writer guidance are documented in ## Repository coordination — Planned for 1.8.7 -The coordination database and durable installation identity are implemented on -the 1.8.7 release branch. Protection of every metadata writer is the next rollout -slice. The later portable-metadata schema revision will add, with explicit -migrations and fixtures: +The coordination database, durable installation identity, and protection of all +existing metadata writers are implemented on the 1.8.7 release branch. The later +portable-metadata schema revision will add, with explicit migrations and fixtures: - durable installation identity distinct from host name; - per-record revision, base revision, writer identity, and timestamp; @@ -76,6 +75,13 @@ acquisition, heartbeat, and expiry. Normal release clears the active row without growing history. Explicit stale takeover preserves the displaced record as diagnostic evidence. +Offline metadata is queued in an app-created, destination-specific temporary +store. It is installed and retired once only when the returning destination has +no metadata database. If destination metadata already exists, VaultSync preserves +both stores and stops; it does not replay a whole queued database over potentially +divergent cross-machine changes. That case remains blocked until the versioned +merge/review workflow can reconcile it. + The migration must preserve every readable version-1 record. A 1.8.7 client may inspect a repository read-only while another valid lease exists, but it must not silently steal or overwrite that lease. Pre-1.8.7 clients cannot participate in diff --git a/docs/wiki/Metadata-Sync.md b/docs/wiki/Metadata-Sync.md index 88abbaac..38d90a71 100644 --- a/docs/wiki/Metadata-Sync.md +++ b/docs/wiki/Metadata-Sync.md @@ -74,6 +74,12 @@ resolution, per-record writer provenance, and a repository-scoped writer lease. Until that ships, use one machine as the writer for a destination and use other machines for recovery inspection or deliberate imports. +On the active 1.8.7 development branch, cooperating metadata writers are now +serialized by a durable repository lease. A second client can still preview and +import read-only, but it cannot write tombstones or exports while the repository +is busy. This protection is not considered shipped, and it cannot constrain a +pre-1.8.7 client that does not understand the protocol. + The maintained 1.8.7 implementation status is recorded in the [1.8.7 release contract](../RELEASE_1.8.7.md). The current and planned on-disk layouts, compatibility rules, and emergency inspection boundary are documented diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index e2c00c3a..d52bef71 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -24,6 +24,8 @@ public sealed class MetadataSyncService private readonly SqliteRepository _repo; private readonly IAppConfigStore _configStore; + private readonly IInstallationIdentityProvider? _installationIdentityProvider; + private readonly RepositoryLeaseService _repositoryLeaseService; private readonly Func? _projectColorResolver; private readonly Action? _projectColorApplier; private readonly ConcurrentDictionary _previewCache = @@ -35,12 +37,16 @@ public MetadataSyncService( SqliteRepository repo, IAppConfigStore? configStore = null, Func? projectColorResolver = null, - Action? projectColorApplier = null) + Action? projectColorApplier = null, + IInstallationIdentityProvider? installationIdentityProvider = null, + RepositoryLeaseService? repositoryLeaseService = null) { _repo = repo ?? throw new ArgumentNullException(nameof(repo)); _configStore = configStore ?? StaticAppConfigStore.Instance; _projectColorResolver = projectColorResolver; _projectColorApplier = projectColorApplier; + _installationIdentityProvider = installationIdentityProvider; + _repositoryLeaseService = repositoryLeaseService ?? new RepositoryLeaseService(); } public MetadataSyncResult ImportFromStore(string rootPath, MetadataSyncOptions? options = null) @@ -67,6 +73,15 @@ public async Task ImportFromStoreAsync(string rootPath, Meta return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); } + RepositoryLeaseInspection leaseInspection = _repositoryLeaseService.Inspect(rootPath); + if (leaseInspection.State is RepositoryLeaseState.Active or + RepositoryLeaseState.Stale or + RepositoryLeaseState.Invalid or + RepositoryLeaseState.Unavailable) + { + opts = opts.AsReadOnlySource(); + } + var store = new MetadataStore(rootPath); if (!File.Exists(store.DatabasePath)) { @@ -1850,54 +1865,82 @@ private static bool IsVaultSyncTransientTempPath(string path) } } - private static void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollection missingExternalIds) + private void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { - TryExportTombstones( + TryExportTombstonesCore( rootPath, BackupEntityType, missingExternalIds, Environment.MachineName, - "Missing backup tombstone export"); + "Missing backup tombstone export", + "unknown", + ResolveLeaseOwnerId(Environment.MachineName), + _repositoryLeaseService); } - private static void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollection missingExternalIds) + private void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { - TryExportTombstones( + TryExportTombstonesCore( rootPath, "snapshot", missingExternalIds, Environment.MachineName, - "Missing snapshot tombstone export"); + "Missing snapshot tombstone export", + "unknown", + ResolveLeaseOwnerId(Environment.MachineName), + _repositoryLeaseService); } - public static void TryExportProjectTombstone(string rootPath, string projectExternalId, string? originMachineId = null) + public static void TryExportProjectTombstone( + string rootPath, + string projectExternalId, + string? originMachineId = null, + string? leaseOwnerId = null) { if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(projectExternalId)) return; string machineId = string.IsNullOrWhiteSpace(originMachineId) ? Environment.MachineName : originMachineId; - TryExportTombstones( + var leaseService = new RepositoryLeaseService(); + TryExportTombstonesCore( rootPath, "project", [projectExternalId], machineId, - "Project tombstone export"); + "Project tombstone export", + "unknown", + leaseOwnerId ?? CreateCompatibilityInstallationId(machineId), + leaseService); } - private static void TryExportTombstones( + private static void TryExportTombstonesCore( string rootPath, string entityType, IReadOnlyCollection externalIds, string machineId, string logLabel, - string appVersion = "unknown") + string appVersion, + string leaseOwnerId, + RepositoryLeaseService leaseService) { if (string.IsNullOrWhiteSpace(rootPath) || externalIds.Count == 0) return; + RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( + rootPath, + CreateLeaseRequest(leaseOwnerId, machineId, logLabel, appVersion)); + if (!leaseResult.Acquired) + { + Console.WriteLine($"[MetadataSync] {logLabel} skipped: {leaseResult.Inspection.Message}"); + return; + } + + using RepositoryLeaseHandle lease = leaseResult.Handle!; var store = new MetadataStore(rootPath); try { + if (!lease.IsOwner) + return; store.EnsureSchema(); } catch (Exception ex) @@ -1916,6 +1959,8 @@ private static void TryExportTombstones( try { + if (!lease.IsOwner) + return; store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); @@ -2002,6 +2047,19 @@ public async Task ExportProjectToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(rootPath)) + return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); + + RepositoryLeaseAcquireResult leaseResult = TryAcquireRepositoryLease( + rootPath, + "project-metadata-export", + appVersion, + machineId); + bool useDeferredStore = leaseResult.Status == RepositoryLeaseAcquireStatus.Unavailable; + if (!leaseResult.Acquired && !useDeferredStore) + return LeaseFailure(leaseResult); + + using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; TimeSpan[] retryDelays = [ TimeSpan.FromMilliseconds(200), @@ -2015,7 +2073,13 @@ public async Task ExportProjectToStoreAsync( { try { - return ExportProjectToStoreInternal(rootPath, projectId, appVersion, machineId); + return ExportProjectToStoreInternal( + rootPath, + projectId, + appVersion, + machineId, + destinationLease, + useDeferredStore); } catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) { @@ -2039,7 +2103,13 @@ public async Task ExportProjectToStoreAsync( } } - private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int projectId, string appVersion, string machineId) + private MetadataSyncResult ExportProjectToStoreInternal( + string rootPath, + int projectId, + string appVersion, + string machineId, + RepositoryLeaseHandle? destinationLease, + bool useDeferredStore) { if (string.IsNullOrWhiteSpace(rootPath)) { @@ -2047,21 +2117,38 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); } - TryFlushDeferredExport(rootPath); - - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) + if (destinationLease is not null) { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; + if (!destinationLease.IsOwner) + return LostLeaseFailure(); + if (HasDeferredExport(rootPath) && + !TryFlushDeferredExport( + rootPath, + appVersion, + machineId, + ResolveLeaseOwnerId(machineId), + _repositoryLeaseService)) + { + return MetadataSyncResult.Failure( + MetadataSyncStatus.RepositoryBusy, + "Deferred metadata was preserved because destination metadata already exists or the queue could not be locked safely."); + } } + string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; + using RepositoryLeaseHandle? deferredLease = useDeferredStore + ? TryAcquireDeferredLease(storeRoot, appVersion, machineId, "deferred-project-metadata-export") + : null; + RepositoryLeaseHandle? activeLease = destinationLease ?? deferredLease; + if (activeLease is null || !activeLease.IsOwner) + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Metadata export could not acquire its deferred writer lease."); + var store = new MetadataStore(storeRoot); Console.WriteLine($"[MetadataSync] Project export target store: '{store.DatabasePath}'."); try { + if (!activeLease.IsOwner) + return LostLeaseFailure(); store.EnsureSchema(); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) @@ -2100,6 +2187,8 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro try { + if (!activeLease.IsOwner) + return LostLeaseFailure(); store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); @@ -2131,11 +2220,8 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro Console.WriteLine($"[MetadataSync] Project export complete for project '{project.Name}' to '{storeRoot}'."); LogStoreCounts(store); - if (isDeferred) + if (useDeferredStore) { - if (TryFlushDeferredExport(rootPath)) - return exportResult; - return MetadataSyncResult.Failure( MetadataSyncStatus.WriteFailed, "Project export queued: destination not writable. Will retry when available."); @@ -2157,6 +2243,19 @@ public async Task ExportBackupToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(rootPath)) + return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); + + RepositoryLeaseAcquireResult leaseResult = TryAcquireRepositoryLease( + rootPath, + "backup-metadata-export", + appVersion, + machineId); + bool useDeferredStore = leaseResult.Status == RepositoryLeaseAcquireStatus.Unavailable; + if (!leaseResult.Acquired && !useDeferredStore) + return LeaseFailure(leaseResult); + + using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; TimeSpan[] retryDelays = [ TimeSpan.FromMilliseconds(200), @@ -2170,7 +2269,14 @@ public async Task ExportBackupToStoreAsync( { try { - return ExportBackupToStoreInternal(rootPath, backupId, appVersion, machineId, forceBackfill); + return ExportBackupToStoreInternal( + rootPath, + backupId, + appVersion, + machineId, + forceBackfill, + destinationLease, + useDeferredStore); } catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) { @@ -2194,7 +2300,14 @@ public async Task ExportBackupToStoreAsync( } } - private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int backupId, string appVersion, string machineId, bool forceBackfill) + private MetadataSyncResult ExportBackupToStoreInternal( + string rootPath, + int backupId, + string appVersion, + string machineId, + bool forceBackfill, + RepositoryLeaseHandle? destinationLease, + bool useDeferredStore) { if (string.IsNullOrWhiteSpace(rootPath)) { @@ -2235,21 +2348,38 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back "Snapshot no longer exists; metadata export skipped."); } - TryFlushDeferredExport(rootPath); - - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) + if (destinationLease is not null) { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; + if (!destinationLease.IsOwner) + return LostLeaseFailure(); + if (HasDeferredExport(rootPath) && + !TryFlushDeferredExport( + rootPath, + appVersion, + machineId, + ResolveLeaseOwnerId(machineId), + _repositoryLeaseService)) + { + return MetadataSyncResult.Failure( + MetadataSyncStatus.RepositoryBusy, + "Deferred metadata was preserved because destination metadata already exists or the queue could not be locked safely."); + } } + string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; + using RepositoryLeaseHandle? deferredLease = useDeferredStore + ? TryAcquireDeferredLease(storeRoot, appVersion, machineId, "deferred-backup-metadata-export") + : null; + RepositoryLeaseHandle? activeLease = destinationLease ?? deferredLease; + if (activeLease is null || !activeLease.IsOwner) + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Metadata export could not acquire its deferred writer lease."); + var store = new MetadataStore(storeRoot); Console.WriteLine($"[MetadataSync] Export target store: '{store.DatabasePath}'."); try { + if (!activeLease.IsOwner) + return LostLeaseFailure(); store.EnsureSchema(); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) @@ -2289,6 +2419,8 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back try { + if (!activeLease.IsOwner) + return LostLeaseFailure(); store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); @@ -2362,11 +2494,8 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back ? $"[MetadataSync] Export complete (backfill) for project '{project.Name}' to '{storeRoot}': snapshots={exportedSnapshots}, backups={exportedBackups}." : $"[MetadataSync] Export complete for backup {backupId} to '{storeRoot}'."); LogStoreCounts(store); - if (isDeferred) + if (useDeferredStore) { - if (TryFlushDeferredExport(rootPath)) - return exportResult; - return MetadataSyncResult.Failure( MetadataSyncStatus.WriteFailed, "Export queued: destination not writable. Will retry when available."); @@ -2375,9 +2504,20 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back return exportResult; } - public static void ExportBackupTombstoneToStore(string rootPath, string backupExternalId, string appVersion, string machineId) + public static void ExportBackupTombstoneToStore( + string rootPath, + string backupExternalId, + string appVersion, + string machineId, + string? leaseOwnerId = null) { - ExportBackupTombstoneToStoreAsync(rootPath, backupExternalId, appVersion, machineId, CancellationToken.None) + ExportBackupTombstoneToStoreAsync( + rootPath, + backupExternalId, + appVersion, + machineId, + CancellationToken.None, + leaseOwnerId) .GetAwaiter().GetResult(); } @@ -2386,7 +2526,8 @@ public static async Task ExportBackupTombstoneToStoreAsync( string backupExternalId, string appVersion, string machineId, - CancellationToken ct = default) + CancellationToken ct = default, + string? leaseOwnerId = null) { if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(backupExternalId)) return; @@ -2396,6 +2537,19 @@ public static async Task ExportBackupTombstoneToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); + var leaseService = new RepositoryLeaseService(); + string ownerId = leaseOwnerId ?? CreateCompatibilityInstallationId(machineId); + RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( + rootPath, + CreateLeaseRequest(ownerId, machineId, "backup-tombstone-export", appVersion)); + bool useDeferredStore = leaseResult.Status == RepositoryLeaseAcquireStatus.Unavailable; + if (!leaseResult.Acquired && !useDeferredStore) + { + Console.WriteLine($"[MetadataSync] Tombstone export skipped: {leaseResult.Inspection.Message}"); + return; + } + + using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; TimeSpan[] retryDelays = [ TimeSpan.FromMilliseconds(200), @@ -2409,7 +2563,15 @@ public static async Task ExportBackupTombstoneToStoreAsync( { try { - ExportBackupTombstoneInternal(rootPath, backupExternalId, appVersion, machineId); + ExportBackupTombstoneInternal( + rootPath, + backupExternalId, + appVersion, + machineId, + ownerId, + leaseService, + destinationLease, + useDeferredStore); return; } catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) @@ -2417,7 +2579,13 @@ public static async Task ExportBackupTombstoneToStoreAsync( if (attempt >= retryDelays.Length) { Console.WriteLine($"[MetadataSync] Tombstone export failed after retries: {ex.Message}"); - TryExportBackupTombstoneToDeferred(rootPath, backupExternalId, appVersion, machineId); + TryExportBackupTombstoneToDeferred( + rootPath, + backupExternalId, + appVersion, + machineId, + ownerId, + leaseService); return; } @@ -2459,12 +2627,24 @@ private static void TryExportBackupTombstoneToDeferred( string rootPath, string backupExternalId, string appVersion, - string machineId) + string machineId, + string leaseOwnerId, + RepositoryLeaseService leaseService) { try { string deferredRoot = GetDeferredExportRoot(rootPath); + Directory.CreateDirectory(deferredRoot); + RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( + deferredRoot, + CreateLeaseRequest(leaseOwnerId, machineId, "deferred-backup-tombstone-export", appVersion)); + if (!leaseResult.Acquired) + return; + + using RepositoryLeaseHandle lease = leaseResult.Handle!; var store = new MetadataStore(deferredRoot); + if (!lease.IsOwner) + return; store.EnsureSchema(); DateTime now = DateTime.UtcNow; @@ -2475,6 +2655,8 @@ private static void TryExportBackupTombstoneToDeferred( machineId, updateExistingAppVersion: true); + if (!lease.IsOwner) + return; store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); @@ -2489,21 +2671,50 @@ private static void TryExportBackupTombstoneToDeferred( } } - private static void ExportBackupTombstoneInternal(string rootPath, string backupExternalId, string appVersion, string machineId) + private static void ExportBackupTombstoneInternal( + string rootPath, + string backupExternalId, + string appVersion, + string machineId, + string leaseOwnerId, + RepositoryLeaseService leaseService, + RepositoryLeaseHandle? destinationLease, + bool useDeferredStore) { - TryFlushDeferredExport(rootPath); - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) + if (destinationLease is not null) { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; + if (!destinationLease.IsOwner) + return; + if (HasDeferredExport(rootPath) && + !TryFlushDeferredExport( + rootPath, + appVersion, + machineId, + leaseOwnerId, + leaseService)) + { + return; + } } + string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; + if (useDeferredStore) + Directory.CreateDirectory(storeRoot); + RepositoryLeaseAcquireResult? deferredLeaseResult = useDeferredStore + ? leaseService.TryAcquire( + storeRoot, + CreateLeaseRequest(leaseOwnerId, machineId, "deferred-backup-tombstone-export", appVersion)) + : null; + using RepositoryLeaseHandle? deferredLease = deferredLeaseResult?.Handle; + RepositoryLeaseHandle? activeLease = destinationLease ?? deferredLease; + if (activeLease is null || !activeLease.IsOwner) + return; + var store = new MetadataStore(storeRoot); try { + if (!activeLease.IsOwner) + return; store.EnsureSchema(); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) @@ -2522,6 +2733,8 @@ private static void ExportBackupTombstoneInternal(string rootPath, string backup try { + if (!activeLease.IsOwner) + return; store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); @@ -2534,15 +2747,83 @@ private static void ExportBackupTombstoneInternal(string rootPath, string backup return; } - if (isDeferred) - { - TryFlushDeferredExport(rootPath); - } + if (useDeferredStore) + Console.WriteLine($"[MetadataSync] Tombstone export queued locally for '{rootPath}'."); } private static string GetMetaDir(string rootPath) => Path.Combine(rootPath, VaultSyncDirectoryName, "meta"); + private RepositoryLeaseAcquireResult TryAcquireRepositoryLease( + string rootPath, + string operation, + string appVersion, + string machineLabel) => + _repositoryLeaseService.TryAcquire( + rootPath, + CreateLeaseRequest( + ResolveLeaseOwnerId(machineLabel), + machineLabel, + operation, + appVersion)); + + private RepositoryLeaseHandle? TryAcquireDeferredLease( + string deferredRoot, + string appVersion, + string machineLabel, + string operation) + { + try + { + Directory.CreateDirectory(deferredRoot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + RepositoryLeaseAcquireResult result = _repositoryLeaseService.TryAcquire( + deferredRoot, + CreateLeaseRequest( + ResolveLeaseOwnerId(machineLabel), + machineLabel, + operation, + appVersion)); + return result.Handle; + } + + private string ResolveLeaseOwnerId(string machineLabel) => + _installationIdentityProvider?.GetOrCreate() ?? + CreateCompatibilityInstallationId(machineLabel); + + private static RepositoryLeaseRequest CreateLeaseRequest( + string installationId, + string machineLabel, + string operation, + string appVersion) => + new( + installationId, + string.IsNullOrWhiteSpace(machineLabel) ? "Unknown host" : machineLabel.Trim(), + operation, + string.IsNullOrWhiteSpace(appVersion) ? "unknown" : appVersion.Trim()); + + private static string CreateCompatibilityInstallationId(string machineLabel) + { + string source = string.IsNullOrWhiteSpace(machineLabel) ? "unknown" : machineLabel.Trim(); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"vaultsync-lease:{source}")); + return new Guid(hash.AsSpan(0, 16)).ToString("N"); + } + + private static MetadataSyncResult LeaseFailure(RepositoryLeaseAcquireResult leaseResult) => + MetadataSyncResult.Failure( + MetadataSyncStatus.RepositoryBusy, + leaseResult.Inspection.Message); + + private static MetadataSyncResult LostLeaseFailure() => + MetadataSyncResult.Failure( + MetadataSyncStatus.RepositoryBusy, + "Repository writer ownership changed before the metadata update could commit."); + private static string GetDeferredExportRoot(string rootPath) { byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rootPath)); @@ -2591,32 +2872,58 @@ private static bool IsLikelyNetworkPath(string path) return false; } - private static bool TryEnsureMetadataDirWritable(string metaDir) + private static bool TryFlushDeferredExport( + string rootPath, + string appVersion, + string machineLabel, + string leaseOwnerId, + RepositoryLeaseService leaseService) { - try + string deferredRoot = GetDeferredExportRoot(rootPath); + if (!File.Exists(new MetadataStore(deferredRoot).DatabasePath)) + return false; + if (File.Exists(new MetadataStore(rootPath).DatabasePath)) + return false; + + RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( + deferredRoot, + CreateLeaseRequest( + leaseOwnerId, + machineLabel, + "deferred-metadata-flush", + appVersion)); + if (!leaseResult.Acquired) + return false; + + bool copied; + using (RepositoryLeaseHandle lease = leaseResult.Handle!) { - string? rootDir = Directory.GetParent(Directory.GetParent(metaDir)?.FullName ?? string.Empty)?.FullName; - if (string.IsNullOrWhiteSpace(rootDir) || !Directory.Exists(rootDir)) + if (!lease.IsOwner) return false; + copied = TryCopyStoreFiles(deferredRoot, rootPath); + } + + return copied && TryRetireDeferredExport(deferredRoot); + } - _ = Directory.CreateDirectory(metaDir); - string probe = Path.Combine(metaDir, ".write_test"); - using var fs = new FileStream(probe, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.DeleteOnClose); - fs.WriteByte(0); + private static bool HasDeferredExport(string rootPath) => + File.Exists(new MetadataStore(GetDeferredExportRoot(rootPath)).DatabasePath); + + private static bool TryRetireDeferredExport(string deferredRoot) + { + string retiredRoot = deferredRoot + ".consumed-" + Guid.NewGuid().ToString("N"); + try + { + Directory.Move(deferredRoot, retiredRoot); + TryDeleteTempStore(retiredRoot); return true; } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return false; } } - private static bool TryFlushDeferredExport(string rootPath) - { - string deferredRoot = GetDeferredExportRoot(rootPath); - return TryCopyStoreFiles(deferredRoot, rootPath); - } - private static bool TryCopyStoreFiles(string fromRoot, string toRoot) { try @@ -3246,7 +3553,10 @@ public sealed record MetadataSyncResult( string Message) { public IReadOnlyCollection AffectedProjectIds { get; init; } = []; - public int RepairedBackups { get; init; } + public int RepairedBackups + { + get; init; + } public static MetadataSyncResult Failure(MetadataSyncStatus status, string message) => new(status, 0, 0, 0, 0, message); @@ -3281,5 +3591,6 @@ public enum MetadataSyncStatus InvalidPath, InvalidStore, Incompatible, + RepositoryBusy, WriteFailed } diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs index d9861895..84046e01 100644 --- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -146,6 +146,15 @@ public RepositoryLeaseAcquireResult TryAcquire(string rootPath, RepositoryLeaseR try { databasePath = GetDatabasePath(rootPath); + if (!Directory.Exists(Path.GetFullPath(rootPath))) + { + return FailedAcquire( + RepositoryLeaseAcquireStatus.Unavailable, + new RepositoryLeaseInspection( + RepositoryLeaseState.Unavailable, + null, + "Repository root is unavailable.")); + } Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); } catch (Exception ex) when (IsStorageException(ex)) @@ -209,7 +218,15 @@ public RepositoryLeaseAcquireResult TakeOverStale( InvalidInspection(validationError ?? "Expected lease nonce is invalid.")); } - string databasePath = GetDatabasePath(rootPath); + string databasePath; + try + { + databasePath = GetDatabasePath(rootPath); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, InvalidInspection("Repository root is invalid.")); + } if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) return FailedAcquire(RepositoryLeaseAcquireStatus.Invalid, InvalidInspection("No valid stale lease is available for takeover.")); @@ -253,7 +270,15 @@ current.Lease is null || public IReadOnlyList ListEvidence(string rootPath) { - string databasePath = GetDatabasePath(rootPath); + string databasePath; + try + { + databasePath = GetDatabasePath(rootPath); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return []; + } if (!File.Exists(databasePath) || IsLinkedFile(databasePath)) return []; @@ -656,7 +681,7 @@ private static bool TryParse( return "Repository operation is required and must not exceed 100 characters."; if (string.IsNullOrWhiteSpace(request.AppVersion) || request.AppVersion.Trim().Length > 64) return "Application version is required and must not exceed 64 characters."; - if (request.HostLabel?.Trim().Length > 200) + if (request.HostLabel is null || request.HostLabel.Trim().Length > 200) return "Host label must not exceed 200 characters."; try diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs index ef73826a..566ec8d3 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs @@ -196,7 +196,8 @@ private void OnBackupRetentionDeleted(Backup backup) backup.DestinationPath, backup.ExternalId, _currentVersionString, - machineId); + machineId, + _installationIdentityProvider.GetOrCreate()); } catch (Exception ex) { @@ -1243,7 +1244,8 @@ private void OnProjectRemovedFromDatabase(int projectId, string externalId) MetadataSyncService.TryExportProjectTombstone( resolution.EffectivePath, externalId, - Environment.MachineName); + Environment.MachineName, + _installationIdentityProvider.GetOrCreate()); } } catch (Exception ex) diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs index 5d6ff878..a6e99068 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs @@ -1223,7 +1223,12 @@ private void TryExportMetadataForBackup( _currentVersionString, machineId, forceBackfill); - Console.WriteLine($"[MetadataSync] Export ({name}) result: {result.Status}."); + Console.WriteLine($"[MetadataSync] Export ({name}) result: {result.Status}; message='{result.Message}'."); + if (result.Status == MetadataSyncStatus.RepositoryBusy) + { + DiagnosticsLogger.Record( + $"[MetadataSync] Repository busy for destination '{name}'; metadata write remained read-only. {result.Message}"); + } if (forceBackfillOverride is null && dest.ForceMetadataBackfill && result.Status == MetadataSyncStatus.Success && diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs index 95bd91da..706c9aab 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs @@ -47,13 +47,15 @@ internal AppViewModel(IAppConfigStore configStore, IRepositoryFactory? repositor _backupService = new BackupService(_repo, configStore: _configStore); _backupService.BackupRetentionDeleted += OnBackupRetentionDeleted; + _installationIdentityProvider = new InstallationIdentityService(); _metadataSyncService = new MetadataSyncService( _repo, _configStore, projectColorResolver: project => AvatarColorProvider.GetColor(project.Name, project.RootPath, project.ExternalId), projectColorApplier: (externalId, color) => - AvatarColorProvider.SetColorForExternalId(externalId, color)); + AvatarColorProvider.SetColorForExternalId(externalId, color), + installationIdentityProvider: _installationIdentityProvider); _networkMountService = new NetworkMountService(); _credentialVault = CredentialVault.Instance; _notificationService = new NotificationService(); diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.cs b/src/VaultSync.UI/ViewModels/AppViewModel.cs index 95830f9f..0a57976c 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.cs @@ -120,6 +120,7 @@ public sealed record DestinationProbeSummary( private readonly BackupService _backupService; private readonly NetworkMountService _networkMountService; private readonly MetadataSyncService _metadataSyncService; + private readonly IInstallationIdentityProvider _installationIdentityProvider; private readonly CredentialVault _credentialVault; private readonly ProjectEncryptionEnrollmentService _projectEncryptionEnrollmentService; private readonly INotificationService _notificationService; diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs index 4ba032e1..abe178c7 100644 --- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs +++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Data.Sqlite; @@ -647,6 +649,59 @@ public void ImportFromStore_ReadOnlySource_SkipsMissingBackupTombstoneWrite() Assert.DoesNotContain("backup-readonly-source-missing", backupTombstones, StringComparer.OrdinalIgnoreCase); } + [Fact] + public void ImportFromStore_ActiveWriterAutomaticallyMakesSourceReadOnly() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + string projectRoot = CreateTempDir(); + MetadataStore store = CreateStore(metaRoot); + SeedMetaInfo(store, "machine-active-writer"); + store.UpsertProject(new MetaProject + { + ExternalId = "proj-busy-source", + Name = "Project Busy Source", + Preset = "unity", + RootPathHint = projectRoot, + CreatedUtc = DateTime.UtcNow.AddDays(-2), + SettingsJson = "{}", + UpdatedUtc = DateTime.UtcNow + }); + store.UpsertSnapshot(new MetaSnapshot + { + ExternalId = "snap-busy-source", + ProjectExternalId = "proj-busy-source", + CreatedUtc = DateTime.UtcNow.AddDays(-1), + FileCount = 5, + TotalBytes = 4096 + }); + store.UpsertBackup(new MetaBackup + { + ExternalId = "backup-busy-source-missing", + ProjectExternalId = "proj-busy-source", + SnapshotExternalId = "snap-busy-source", + CreatedUtc = DateTime.UtcNow.AddMinutes(-1), + Type = "manual", + TotalBytes = 4096, + PathRel = "missing-folder/backup-1", + DestinationAlias = "Primary", + KdfParamsJson = "{}" + }); + + var leaseService = new RepositoryLeaseService(); + using RepositoryLeaseHandle writer = AcquireTestLease(leaseService, metaRoot, "another-writer"); + SqliteRepository repo = CreateRepository(dbPath); + var service = new MetadataSyncService(repo, repositoryLeaseService: leaseService); + + MetadataSyncResult result = service.ImportFromStore(metaRoot, MetadataSyncOptions.Default); + + Assert.Equal(MetadataSyncStatus.Success, result.Status); + Assert.DoesNotContain( + new MetadataStore(metaRoot).ListTombstones(), + tombstone => string.Equals(tombstone.EntityId, "backup-busy-source-missing", StringComparison.Ordinal)); + Assert.True(writer.IsOwner); + } + [Fact] public void ImportFromStore_CanSkipRestoreFlag() { @@ -954,6 +1009,160 @@ public void ExportBackupToStore_WritesMetadata() Assert.False(string.IsNullOrWhiteSpace(updatedBackup?.ExternalId)); } + [Fact] + public void ExportBackupToStore_ActiveWriterBlocksMetadataMutation() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = TestRepository.AddProject(repo, "Project Busy Export", CreateTempDir(), "unity", DateTime.UtcNow); + int snapshotId = repo.CreateSnapshot(projectId, 2, 500); + int backupId = repo.CreateBackup( + projectId, + snapshotId, + "manual", + 500, + "project-busy/2026-08-12_00-00-00", + metaRoot, + "Primary"); + var leaseService = new RepositoryLeaseService(); + using RepositoryLeaseHandle writer = AcquireTestLease(leaseService, metaRoot, "another-writer"); + var service = new MetadataSyncService(repo, repositoryLeaseService: leaseService); + + MetadataSyncResult result = service.ExportBackupToStore(metaRoot, backupId, "1.8.7", "machine-local"); + + Assert.Equal(MetadataSyncStatus.RepositoryBusy, result.Status); + Assert.False(File.Exists(new MetadataStore(metaRoot).DatabasePath)); + Assert.True(writer.IsOwner); + } + + [Fact] + public void ExportProjectToStore_ActiveWriterBlocksMetadataMutation() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = TestRepository.AddProject(repo, "Project Busy Settings", CreateTempDir(), "unity", DateTime.UtcNow); + var leaseService = new RepositoryLeaseService(); + using RepositoryLeaseHandle writer = AcquireTestLease(leaseService, metaRoot, "another-writer"); + var service = new MetadataSyncService(repo, repositoryLeaseService: leaseService); + + MetadataSyncResult result = service.ExportProjectToStore(metaRoot, projectId, "1.8.7", "machine-local"); + + Assert.Equal(MetadataSyncStatus.RepositoryBusy, result.Status); + Assert.False(File.Exists(new MetadataStore(metaRoot).DatabasePath)); + Assert.True(writer.IsOwner); + } + + [Fact] + public void ExportBackupToStore_DeferredQueueFlushesOnceAndIsRemoved() + { + string unavailableRoot = Path.Combine(CreateTempDir(), "offline-destination"); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = TestRepository.AddProject(repo, "Project Deferred Export", CreateTempDir(), "unity", DateTime.UtcNow); + int snapshotId = repo.CreateSnapshot(projectId, 2, 500); + int backupId = repo.CreateBackup( + projectId, + snapshotId, + "manual", + 500, + "project-deferred/2026-08-12_00-00-00", + unavailableRoot, + "Offline"); + var service = new MetadataSyncService(repo); + string deferredRoot = GetExpectedDeferredRoot(unavailableRoot); + + MetadataSyncResult queued = service.ExportBackupToStore( + unavailableRoot, + backupId, + "1.8.7", + "machine-local"); + Assert.Equal(MetadataSyncStatus.WriteFailed, queued.Status); + Assert.True(File.Exists(new MetadataStore(deferredRoot).DatabasePath)); + + Directory.CreateDirectory(unavailableRoot); + MetadataSyncResult flushed = service.ExportBackupToStore( + unavailableRoot, + backupId, + "1.8.7", + "machine-local"); + + Assert.Equal(MetadataSyncStatus.Success, flushed.Status); + Assert.False(Directory.Exists(deferredRoot)); + Assert.Single(new MetadataStore(unavailableRoot).ListBackups()); + } + + [Fact] + public void ExportBackupToStore_DeferredQueueCannotOverwriteDivergedDestination() + { + string unavailableRoot = Path.Combine(CreateTempDir(), "diverged-destination"); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = TestRepository.AddProject(repo, "Project Deferred Conflict", CreateTempDir(), "unity", DateTime.UtcNow); + int snapshotId = repo.CreateSnapshot(projectId, 2, 500); + int backupId = repo.CreateBackup( + projectId, + snapshotId, + "manual", + 500, + "project-conflict/2026-08-12_00-00-00", + unavailableRoot, + "Offline"); + var service = new MetadataSyncService(repo); + string deferredRoot = GetExpectedDeferredRoot(unavailableRoot); + + MetadataSyncResult queued = service.ExportBackupToStore( + unavailableRoot, + backupId, + "1.8.7", + "machine-local"); + Assert.Equal(MetadataSyncStatus.WriteFailed, queued.Status); + + Directory.CreateDirectory(unavailableRoot); + MetadataStore destinationStore = CreateStore(unavailableRoot); + destinationStore.UpsertProject(new MetaProject + { + ExternalId = "remote-project", + Name = "Remote Project", + Preset = "generic", + RootPathHint = "/remote/project", + CreatedUtc = DateTime.UtcNow, + SettingsJson = "{}", + UpdatedUtc = DateTime.UtcNow + }); + + MetadataSyncResult blocked = service.ExportBackupToStore( + unavailableRoot, + backupId, + "1.8.7", + "machine-local"); + + Assert.Equal(MetadataSyncStatus.RepositoryBusy, blocked.Status); + Assert.True(File.Exists(new MetadataStore(deferredRoot).DatabasePath)); + MetaProject remote = Assert.Single(new MetadataStore(unavailableRoot).ListProjects()); + Assert.Equal("remote-project", remote.ExternalId); + Directory.Delete(deferredRoot, recursive: true); + } + + [Fact] + public async System.Threading.Tasks.Task ExportBackupTombstoneToStoreAsync_ActiveWriterBlocksTombstone() + { + string metaRoot = CreateTempDir(); + var leaseService = new RepositoryLeaseService(); + using RepositoryLeaseHandle writer = AcquireTestLease(leaseService, metaRoot, "another-writer"); + + await MetadataSyncService.ExportBackupTombstoneToStoreAsync( + metaRoot, + "backup-must-not-write", + "1.8.7", + "machine-local", + leaseOwnerId: Guid.NewGuid().ToString("N")); + + Assert.False(File.Exists(new MetadataStore(metaRoot).DatabasePath)); + Assert.True(writer.IsOwner); + } + [Fact] public void ExportBackupToStore_MissingBackup_SkipsWithoutCreatingStore() { @@ -1901,6 +2110,31 @@ private static void SeedMetaInfo(MetadataStore store, string machineId) }); } + private static RepositoryLeaseHandle AcquireTestLease( + RepositoryLeaseService service, + string rootPath, + string operation) + { + RepositoryLeaseAcquireResult result = service.TryAcquire( + rootPath, + new RepositoryLeaseRequest( + Guid.NewGuid().ToString("N"), + "Test writer", + operation, + "1.8.7")); + Assert.Equal(RepositoryLeaseAcquireStatus.Acquired, result.Status); + return Assert.IsType(result.Handle); + } + + private static string GetExpectedDeferredRoot(string rootPath) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rootPath)); + return Path.Combine( + Path.GetTempPath(), + "vaultsync-meta-export", + HashService.FormatHexLower(hash)); + } + private static void SeedUnrelatedImportedHistory(SqliteRepository repo, string rootPath) { int projectId = repo.AddProject(new Project diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs index ccd7807f..40d879b9 100644 --- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -24,6 +24,21 @@ public void Inspect_MissingCoordinationDatabaseIsAvailableAndReadOnly() Assert.False(File.Exists(service.GetDatabasePath(root.Path))); } + [Fact] + public void TryAcquire_UnavailableRootDoesNotCreateDestination() + { + using var parent = new TempDirectory(); + string unavailableRoot = Path.Combine(parent.Path, "offline"); + var service = new RepositoryLeaseService(); + + RepositoryLeaseAcquireResult result = service.TryAcquire( + unavailableRoot, + CreateRequest("metadata-export")); + + Assert.Equal(RepositoryLeaseAcquireStatus.Unavailable, result.Status); + Assert.False(Directory.Exists(unavailableRoot)); + } + [Fact] public void TryAcquire_SecondWriterIsBusyAndReadOnlyInspectionRemainsAvailable() { From b45c290997a322b18ab4d371d6ccc1e51679f78f Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:28:08 +0200 Subject: [PATCH 10/54] Release coordination database handles --- src/VaultSync.Core/Services/RepositoryLeaseService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs index 84046e01..9c9691f1 100644 --- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -720,7 +720,8 @@ private static SqliteConnection OpenConnection(string databasePath, bool readOnl DataSource = databasePath, Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Private, - DefaultTimeout = 5 + DefaultTimeout = 5, + Pooling = false }; var connection = new SqliteConnection(builder.ToString()); connection.Open(); From f41e89331967950494dcf31f990815dc195be267 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:38:34 +0200 Subject: [PATCH 11/54] Clear cross-machine quality gates --- .../Services/MetadataSyncService.cs | 260 +++++++++--------- .../Services/RepositoryLeaseService.cs | 37 +-- .../RepositoryLeaseServiceTests.cs | 81 ++++++ 3 files changed, 214 insertions(+), 164 deletions(-) diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index d52bef71..c39707d7 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -21,6 +21,15 @@ public sealed class MetadataSyncService private const string BackupEntityType = "backup"; private const string InvalidRootPathMessage = "Root path is empty."; private const string VaultSyncDirectoryName = ".vaultsync"; + private const string UnknownAppVersion = "unknown"; + private static readonly TimeSpan[] StoreRetryDelays = + [ + TimeSpan.FromMilliseconds(200), + TimeSpan.FromMilliseconds(500), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5) + ]; private readonly SqliteRepository _repo; private readonly IAppConfigStore _configStore; @@ -1873,7 +1882,7 @@ private void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollecti missingExternalIds, Environment.MachineName, "Missing backup tombstone export", - "unknown", + UnknownAppVersion, ResolveLeaseOwnerId(Environment.MachineName), _repositoryLeaseService); } @@ -1886,7 +1895,7 @@ private void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollec missingExternalIds, Environment.MachineName, "Missing snapshot tombstone export", - "unknown", + UnknownAppVersion, ResolveLeaseOwnerId(Environment.MachineName), _repositoryLeaseService); } @@ -1908,7 +1917,7 @@ public static void TryExportProjectTombstone( [projectExternalId], machineId, "Project tombstone export", - "unknown", + UnknownAppVersion, leaseOwnerId ?? CreateCompatibilityInstallationId(machineId), leaseService); } @@ -2060,47 +2069,75 @@ public async Task ExportProjectToStoreAsync( return LeaseFailure(leaseResult); using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; - TimeSpan[] retryDelays = - [ - TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5) - ]; - - for (int attempt = 0; attempt <= retryDelays.Length; attempt++) - { - try - { - return ExportProjectToStoreInternal( + return await ExecuteStoreWriteWithRetryAsync( + () => ExportProjectToStoreInternal( rootPath, projectId, appVersion, machineId, destinationLease, - useDeferredStore); - } - catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) - { - if (attempt >= retryDelays.Length) - { - Console.WriteLine($"[MetadataSync] Project export failed after retries: {ex.Message}"); - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, ex.Message); - } + useDeferredStore), + "Project export", + ct) + .ConfigureAwait(false); + } + finally + { + metadataIoGate.Release(); + } + } - TimeSpan delay = retryDelays[attempt]; - Console.WriteLine($"[MetadataSync] Project export store locked; retrying in {delay.TotalMilliseconds:0}ms."); - await Task.Delay(delay, ct).ConfigureAwait(false); - } + private static async Task ExecuteStoreWriteWithRetryAsync( + Func write, + string operationLabel, + CancellationToken ct) + { + for (int attempt = 0; ; attempt++) + { + try + { + return write(); } + catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) + { + if (attempt >= StoreRetryDelays.Length) + { + Console.WriteLine($"[MetadataSync] {operationLabel} failed after retries: {ex.Message}"); + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, ex.Message); + } - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Project export failed after retries."); + TimeSpan delay = StoreRetryDelays[attempt]; + Console.WriteLine($"[MetadataSync] {operationLabel} store locked; retrying in {delay.TotalMilliseconds:0}ms."); + await Task.Delay(delay, ct).ConfigureAwait(false); + } } - finally + } + + private MetadataSyncResult? ValidateDestinationLease( + string rootPath, + string appVersion, + string machineId, + RepositoryLeaseHandle? destinationLease) + { + if (destinationLease is null) + return null; + if (!destinationLease.IsOwner) + return LostLeaseFailure(); + if (!HasDeferredExport(rootPath)) + return null; + if (TryFlushDeferredExport( + rootPath, + appVersion, + machineId, + ResolveLeaseOwnerId(machineId), + _repositoryLeaseService)) { - metadataIoGate.Release(); + return null; } + + return MetadataSyncResult.Failure( + MetadataSyncStatus.RepositoryBusy, + "Deferred metadata was preserved because destination metadata already exists or the queue could not be locked safely."); } private MetadataSyncResult ExportProjectToStoreInternal( @@ -2117,23 +2154,10 @@ private MetadataSyncResult ExportProjectToStoreInternal( return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); } - if (destinationLease is not null) - { - if (!destinationLease.IsOwner) - return LostLeaseFailure(); - if (HasDeferredExport(rootPath) && - !TryFlushDeferredExport( - rootPath, - appVersion, - machineId, - ResolveLeaseOwnerId(machineId), - _repositoryLeaseService)) - { - return MetadataSyncResult.Failure( - MetadataSyncStatus.RepositoryBusy, - "Deferred metadata was preserved because destination metadata already exists or the queue could not be locked safely."); - } - } + MetadataSyncResult? destinationFailure = ValidateDestinationLease( + rootPath, appVersion, machineId, destinationLease); + if (destinationFailure is not null) + return destinationFailure; string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; using RepositoryLeaseHandle? deferredLease = useDeferredStore @@ -2256,43 +2280,18 @@ public async Task ExportBackupToStoreAsync( return LeaseFailure(leaseResult); using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; - TimeSpan[] retryDelays = - [ - TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5) - ]; - - for (int attempt = 0; attempt <= retryDelays.Length; attempt++) - { - try - { - return ExportBackupToStoreInternal( + return await ExecuteStoreWriteWithRetryAsync( + () => ExportBackupToStoreInternal( rootPath, backupId, appVersion, machineId, forceBackfill, destinationLease, - useDeferredStore); - } - catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) - { - if (attempt >= retryDelays.Length) - { - Console.WriteLine($"[MetadataSync] Export failed after retries: {ex.Message}"); - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, ex.Message); - } - - TimeSpan delay = retryDelays[attempt]; - Console.WriteLine($"[MetadataSync] Export store locked; retrying in {delay.TotalMilliseconds:0}ms."); - await Task.Delay(delay, ct).ConfigureAwait(false); - } - } - - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Export failed after retries."); + useDeferredStore), + "Backup export", + ct) + .ConfigureAwait(false); } finally { @@ -2348,23 +2347,10 @@ private MetadataSyncResult ExportBackupToStoreInternal( "Snapshot no longer exists; metadata export skipped."); } - if (destinationLease is not null) - { - if (!destinationLease.IsOwner) - return LostLeaseFailure(); - if (HasDeferredExport(rootPath) && - !TryFlushDeferredExport( - rootPath, - appVersion, - machineId, - ResolveLeaseOwnerId(machineId), - _repositoryLeaseService)) - { - return MetadataSyncResult.Failure( - MetadataSyncStatus.RepositoryBusy, - "Deferred metadata was preserved because destination metadata already exists or the queue could not be locked safely."); - } - } + MetadataSyncResult? destinationFailure = ValidateDestinationLease( + rootPath, appVersion, machineId, destinationLease); + if (destinationFailure is not null) + return destinationFailure; string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; using RepositoryLeaseHandle? deferredLease = useDeferredStore @@ -2456,23 +2442,12 @@ private MetadataSyncResult ExportBackupToStoreInternal( DiffNetBytes = snapshot.DiffNetBytes, DiffTopPathsJson = string.IsNullOrWhiteSpace(snapshot.DiffTopPathsJson) ? "[]" : snapshot.DiffTopPathsJson }); - var descriptor = BackupCryptoDescriptor.FromMetadata(backup.IsEncrypted, backup.CryptoDescriptorJson); - store.UpsertBackup(new MetaBackup - { - ExternalId = backupExternalId, - ProjectExternalId = projectExternalId, - SnapshotExternalId = snapshotExternalId, - CreatedUtc = backup.CreatedUtc, - Type = backup.Type, - BackupMode = BackupModes.Normalize(backup.BackupMode), - TotalBytes = backup.TotalBytes, - PathRel = backup.Path, - DestinationAlias = backup.DestinationAlias ?? string.Empty, - OriginMachineName = machineId, - IsProtected = backup.IsProtected, - IsEncrypted = backup.IsEncrypted, - KdfParamsJson = descriptor.ToMetadataJson(backup.IsEncrypted) - }); + store.UpsertBackup(CreateMetaBackup( + backup, + backupExternalId, + projectExternalId, + snapshotExternalId, + machineId)); exportedBackups = 1; } }); @@ -2805,11 +2780,11 @@ private static RepositoryLeaseRequest CreateLeaseRequest( installationId, string.IsNullOrWhiteSpace(machineLabel) ? "Unknown host" : machineLabel.Trim(), operation, - string.IsNullOrWhiteSpace(appVersion) ? "unknown" : appVersion.Trim()); + string.IsNullOrWhiteSpace(appVersion) ? UnknownAppVersion : appVersion.Trim()); private static string CreateCompatibilityInstallationId(string machineLabel) { - string source = string.IsNullOrWhiteSpace(machineLabel) ? "unknown" : machineLabel.Trim(); + string source = string.IsNullOrWhiteSpace(machineLabel) ? UnknownAppVersion : machineLabel.Trim(); byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"vaultsync-lease:{source}")); return new Guid(hash.AsSpan(0, 16)).ToString("N"); } @@ -3032,23 +3007,12 @@ private static bool TryCopyStoreFiles(string fromRoot, string toRoot) } string backupExternalId = EnsureBackupExternalId(backup); - var descriptor = BackupCryptoDescriptor.FromMetadata(backup.IsEncrypted, backup.CryptoDescriptorJson); - store.UpsertBackup(new MetaBackup - { - ExternalId = backupExternalId, - ProjectExternalId = projectExternalId, - SnapshotExternalId = snapshotExternalId, - CreatedUtc = backup.CreatedUtc, - Type = backup.Type, - BackupMode = BackupModes.Normalize(backup.BackupMode), - TotalBytes = backup.TotalBytes, - PathRel = backup.Path, - DestinationAlias = backup.DestinationAlias ?? string.Empty, - OriginMachineName = machineId, - IsProtected = backup.IsProtected, - IsEncrypted = backup.IsEncrypted, - KdfParamsJson = descriptor.ToMetadataJson(backup.IsEncrypted) - }); + store.UpsertBackup(CreateMetaBackup( + backup, + backupExternalId, + projectExternalId, + snapshotExternalId, + machineId)); exportedBackups++; } @@ -3060,6 +3024,32 @@ private static bool TryCopyStoreFiles(string fromRoot, string toRoot) return (snapshots.Count, exportedBackups); } + private static MetaBackup CreateMetaBackup( + Backup backup, + string backupExternalId, + string projectExternalId, + string snapshotExternalId, + string machineId) + { + var descriptor = BackupCryptoDescriptor.FromMetadata(backup.IsEncrypted, backup.CryptoDescriptorJson); + return new MetaBackup + { + ExternalId = backupExternalId, + ProjectExternalId = projectExternalId, + SnapshotExternalId = snapshotExternalId, + CreatedUtc = backup.CreatedUtc, + Type = backup.Type, + BackupMode = BackupModes.Normalize(backup.BackupMode), + TotalBytes = backup.TotalBytes, + PathRel = backup.Path, + DestinationAlias = backup.DestinationAlias ?? string.Empty, + OriginMachineName = machineId, + IsProtected = backup.IsProtected, + IsEncrypted = backup.IsEncrypted, + KdfParamsJson = descriptor.ToMetadataJson(backup.IsEncrypted) + }; + } + private static void LogStoreCounts(MetadataStore store) { try @@ -3440,7 +3430,7 @@ private static bool UpsertProjectMetadataConflict( ProjectId = current.Id, ProjectExternalId = string.IsNullOrWhiteSpace(current.ExternalId) ? metaProject.ExternalId : current.ExternalId, ProjectName = current.Name, - SourceMachineId = string.IsNullOrWhiteSpace(sourceMachineId) ? "unknown" : sourceMachineId, + SourceMachineId = string.IsNullOrWhiteSpace(sourceMachineId) ? UnknownAppVersion : sourceMachineId, SourceUpdatedUtc = metaProject.UpdatedUtc == default ? string.Empty : metaProject.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs index 9c9691f1..df495d3b 100644 --- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -440,7 +440,7 @@ private RepositoryLeaseAcquireResult AcquiredResult( internal ITimer CreateHeartbeatTimer(TimerCallback callback, object state, TimeSpan interval) => _timeProvider.CreateTimer(callback, state, interval, interval); - private RepositoryLeaseSnapshot CreateSnapshot(RepositoryLeaseRequest request, DateTimeOffset now) + private static RepositoryLeaseSnapshot CreateSnapshot(RepositoryLeaseRequest request, DateTimeOffset now) { TimeSpan duration = ResolveDuration(request.Duration); return new RepositoryLeaseSnapshot( @@ -812,19 +812,10 @@ private static RepositoryLeaseAcquireResult FailedAcquire( private sealed class LeaseRow { - public int ProtocolVersion - { - get; set; - } + public int ProtocolVersion { get; set; } = CurrentProtocolVersion; public string InstallationId { get; set; } = string.Empty; - public string? HostLabel - { - get; set; - } - public int ProcessId - { - get; set; - } + public string? HostLabel { get; set; } = string.Empty; + public int ProcessId { get; set; } = Environment.ProcessId; public string Operation { get; set; } = string.Empty; public string Nonce { get; set; } = string.Empty; public string AppVersion { get; set; } = string.Empty; @@ -835,20 +826,11 @@ public int ProcessId private sealed class LeaseParameters { - public int LeaseId - { - get; set; - } - public int ProtocolVersion - { - get; set; - } + public int LeaseId { get; set; } + public int ProtocolVersion { get; set; } = CurrentProtocolVersion; public string InstallationId { get; set; } = string.Empty; public string HostLabel { get; set; } = string.Empty; - public int ProcessId - { - get; set; - } + public int ProcessId { get; set; } = Environment.ProcessId; public string Operation { get; set; } = string.Empty; public string Nonce { get; set; } = string.Empty; public string AppVersion { get; set; } = string.Empty; @@ -861,10 +843,7 @@ private sealed class LeaseEvidenceRow { public string Nonce { get; set; } = string.Empty; public string InstallationId { get; set; } = string.Empty; - public string? HostLabel - { - get; set; - } + public string? HostLabel { get; set; } = string.Empty; public string Operation { get; set; } = string.Empty; public string AppVersion { get; set; } = string.Empty; public string AcquiredUtc { get; set; } = string.Empty; diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs index 40d879b9..22edd6ca 100644 --- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -12,6 +12,24 @@ namespace VaultSync.Core.Tests; public sealed class RepositoryLeaseServiceTests { + [Fact] + public void Constructor_RejectsUnsupportedClockSkewTolerance() + { + Assert.Throws(() => + new RepositoryLeaseService(clockSkewTolerance: TimeSpan.FromMinutes(11))); + } + + [Fact] + public void Inspect_EmptyRootIsInvalid() + { + var service = new RepositoryLeaseService(); + + RepositoryLeaseInspection inspection = service.Inspect(string.Empty); + + Assert.Equal(RepositoryLeaseState.Invalid, inspection.State); + Assert.Null(inspection.Lease); + } + [Fact] public void Inspect_MissingCoordinationDatabaseIsAvailableAndReadOnly() { @@ -157,6 +175,69 @@ public void TryAcquire_InvalidInstallationIdentityFailsWithoutCreatingDatabase(s Assert.False(File.Exists(service.GetDatabasePath(root.Path))); } + [Theory] + [InlineData("operation")] + [InlineData("version")] + [InlineData("host")] + [InlineData("duration")] + public void TryAcquire_InvalidRequestFieldsFailClosed(string invalidField) + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + RepositoryLeaseRequest request = CreateRequest("metadata-export") with + { + Operation = invalidField == "operation" ? string.Empty : "metadata-export", + AppVersion = invalidField == "version" ? string.Empty : "1.8.7", + HostLabel = invalidField == "host" ? new string('h', 201) : "Test host", + Duration = invalidField == "duration" ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(5) + }; + + RepositoryLeaseAcquireResult result = service.TryAcquire(root.Path, request); + + Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, result.Status); + Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + } + + [Fact] + public void Inspect_ExistingDatabaseWithoutLeaseSchemaIsInvalid() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + string databasePath = service.GetDatabasePath(root.Path); + Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); + using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) + { + connection.Open(); + connection.Execute("CREATE TABLE unrelated(id INTEGER PRIMARY KEY);"); + } + + RepositoryLeaseInspection inspection = service.Inspect(root.Path); + + Assert.Equal(RepositoryLeaseState.Invalid, inspection.State); + } + + [Fact] + public void ListEvidence_MissingDatabaseReturnsEmpty() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + + Assert.Empty(service.ListEvidence(root.Path)); + } + + [Fact] + public void TakeOverStale_InvalidNonceFailsWithoutChangingOwner() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + using RepositoryLeaseHandle owner = AssertAcquired(service.TryAcquire(root.Path, CreateRequest("metadata-export"))); + + RepositoryLeaseAcquireResult result = service.TakeOverStale(root.Path, "invalid", CreateRequest("takeover")); + + Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, result.Status); + Assert.True(owner.IsOwner); + } + [Fact] public void Inspect_MalformedLeaseFailsClosed() { From a7dfbd1fe00db4856dccb5a1f91d74a017012886 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:45:20 +0200 Subject: [PATCH 12/54] Start the 1.8.7 changelog --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c66f3189..07aeb5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ # Changelog +## [1.8.7] - Unreleased +### Added +- [VS-1877] Added a durable, owner-private installation identity for cross-machine coordination without treating mutable host names or telemetry identifiers as writer identity. +- [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. + +### Changed +- [BUG-18099] Serviced the .NET 10 baseline to SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft packages, with CI auditing real self-contained publishes and release artifacts for every supported runtime identifier. +- [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. +- [VS-1877] Made unavailable-destination metadata queues fail closed: queued metadata can initialize an empty destination once, while an existing destination is preserved for explicit merge review. + +### Fixed +- [BUG-18100] Restored `Dev` as the permanent integration branch at the `1.8.6` Stable commit and disabled automatic head-branch deletion so Stable promotion cannot remove it again. +- [BUG-18102] Prevented deferred metadata replay from overwriting repository metadata changed on another machine or replaying repeatedly after a successful flush. +- [BUG-18102] Disabled connection pooling for the repository coordination database so disposed writer leases release their file handles predictably on Windows. + ## [1.8.6] - 10.08.2026 ### Added - [VS-1861] Replaced first-run overlays with a compact, resumable task sequence driven by real source, destination, project, schedule, restore-point, and passed recovery-drill state. From 6a5f3ab7db2a92831c8a8df7c425212d4c57bcb8 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:48:57 +0200 Subject: [PATCH 13/54] Plan 1.8.7 codebase simplification --- ROADMAP.md | 12 ++++++++++++ docs/RELEASE_1.8.7.md | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index cec032bf..aabcb1e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -454,6 +454,18 @@ that exists only on the release branch or remains planned. another; non-overlapping changes merge, overlapping changes show old, local, and remote values with timestamps and writers; accepting either side is durable and auditable; the operation can be undone before the next write. +- [ ] `VS-1880` `P1` Simplify and standardize shared application code without + changing user-visible behavior. + - Scope: consolidate repeated retry, path, serialization, status, dialog, + lifecycle, and projection logic behind focused tested primitives; decompose + oversized backup, metadata, Dashboard, Settings, Projects, and history + orchestration; remove confirmed dead code; and document the few intentional + platform-specific duplications that cannot safely share an implementation. + - Acceptance: every touched behavior retains regression coverage, no new + Sonar duplication is introduced, the repository duplication baseline falls + release over release, all remaining duplicated blocks are reviewed and + justified or tracked, and builds remain warning-free on every supported + platform. ### Confirmed defects entering 1.8.7 diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 1209c1ea..fbc5721b 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -61,6 +61,9 @@ branch package. 3. Generate the release manifest and expose complete build identity. 4. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB behavior before enabling supported concurrent-machine workflows. +5. Reduce codebase duplication and oversized orchestration through shared, + regression-tested primitives without combining genuinely different platform + behavior. ### Still planned @@ -68,6 +71,8 @@ branch package. - checksummed Recovery Evidence Packages; - allowlisted, reviewable support bundles; - synchronized public release metadata; +- standardized retry, path, serialization, lifecycle, dialog, and projection + infrastructure plus review of every remaining duplicated block; - full repository and emergency-recovery documentation after schemas stabilize; - final localization, theme, accessibility, static-analysis, dependency, and cross-platform release qualification. From 1cb932b9d9922274e5755aa887cf166bdb2498b7 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:01:56 +0200 Subject: [PATCH 14/54] Clear metadata sync analyzer findings --- .../Services/MetadataSyncService.cs | 486 ++++++++++-------- .../Services/RepositoryLeaseService.cs | 2 +- src/VaultSync.UI/ViewModels/AppViewModel.cs | 2 +- .../RepositoryLeaseServiceTests.cs | 10 +- 4 files changed, 267 insertions(+), 233 deletions(-) diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index c39707d7..e1c0fcf9 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -31,6 +31,27 @@ public sealed class MetadataSyncService TimeSpan.FromSeconds(5) ]; + private sealed record TombstoneExportContext( + string RootPath, + string EntityType, + IReadOnlyCollection ExternalIds, + string MachineId, + string LogLabel, + string AppVersion, + string LeaseOwnerId); + + private sealed record BackupExportEntities(Backup Backup, Project Project, Snapshot Snapshot); + + private sealed record BackupExportCounts(int Projects, int Snapshots, int Backups, bool Backfilled); + + private sealed record BackupExportWriteContext( + string ProjectExternalId, + string SnapshotExternalId, + string BackupExternalId, + DateTime Now, + string MachineId, + bool ForceBackfill); + private readonly SqliteRepository _repo; private readonly IAppConfigStore _configStore; private readonly IInstallationIdentityProvider? _installationIdentityProvider; @@ -1876,27 +1897,31 @@ private static bool IsVaultSyncTransientTempPath(string path) private void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { + string machineId = Environment.MachineName; TryExportTombstonesCore( - rootPath, - BackupEntityType, - missingExternalIds, - Environment.MachineName, - "Missing backup tombstone export", - UnknownAppVersion, - ResolveLeaseOwnerId(Environment.MachineName), + new TombstoneExportContext( + rootPath, + BackupEntityType, + missingExternalIds, + machineId, + "Missing backup tombstone export", + UnknownAppVersion, + ResolveLeaseOwnerId(machineId)), _repositoryLeaseService); } private void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { + string machineId = Environment.MachineName; TryExportTombstonesCore( - rootPath, - "snapshot", - missingExternalIds, - Environment.MachineName, - "Missing snapshot tombstone export", - UnknownAppVersion, - ResolveLeaseOwnerId(Environment.MachineName), + new TombstoneExportContext( + rootPath, + "snapshot", + missingExternalIds, + machineId, + "Missing snapshot tombstone export", + UnknownAppVersion, + ResolveLeaseOwnerId(machineId)), _repositoryLeaseService); } @@ -1912,40 +1937,35 @@ public static void TryExportProjectTombstone( string machineId = string.IsNullOrWhiteSpace(originMachineId) ? Environment.MachineName : originMachineId; var leaseService = new RepositoryLeaseService(); TryExportTombstonesCore( - rootPath, - "project", - [projectExternalId], - machineId, - "Project tombstone export", - UnknownAppVersion, - leaseOwnerId ?? CreateCompatibilityInstallationId(machineId), + new TombstoneExportContext( + rootPath, + "project", + [projectExternalId], + machineId, + "Project tombstone export", + UnknownAppVersion, + leaseOwnerId ?? CreateCompatibilityInstallationId(machineId)), leaseService); } private static void TryExportTombstonesCore( - string rootPath, - string entityType, - IReadOnlyCollection externalIds, - string machineId, - string logLabel, - string appVersion, - string leaseOwnerId, + TombstoneExportContext context, RepositoryLeaseService leaseService) { - if (string.IsNullOrWhiteSpace(rootPath) || externalIds.Count == 0) + if (string.IsNullOrWhiteSpace(context.RootPath) || context.ExternalIds.Count == 0) return; RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( - rootPath, - CreateLeaseRequest(leaseOwnerId, machineId, logLabel, appVersion)); + context.RootPath, + CreateLeaseRequest(context.LeaseOwnerId, context.MachineId, context.LogLabel, context.AppVersion)); if (!leaseResult.Acquired) { - Console.WriteLine($"[MetadataSync] {logLabel} skipped: {leaseResult.Inspection.Message}"); + Console.WriteLine($"[MetadataSync] {context.LogLabel} skipped: {leaseResult.Inspection.Message}"); return; } using RepositoryLeaseHandle lease = leaseResult.Handle!; - var store = new MetadataStore(rootPath); + var store = new MetadataStore(context.RootPath); try { if (!lease.IsOwner) @@ -1954,7 +1974,7 @@ private static void TryExportTombstonesCore( } catch (Exception ex) { - Console.WriteLine($"[MetadataSync] {logLabel} failed: store init error at '{rootPath}': {ex.Message}"); + Console.WriteLine($"[MetadataSync] {context.LogLabel} failed: store init error at '{context.RootPath}': {ex.Message}"); return; } @@ -1962,8 +1982,8 @@ private static void TryExportTombstonesCore( MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: false); try @@ -1973,12 +1993,12 @@ private static void TryExportTombstonesCore( store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); - AddTombstones(store, externalIds, entityType, now, machineId); + AddTombstones(store, context.ExternalIds, context.EntityType, now, context.MachineId); }); } catch (Exception ex) { - Console.WriteLine($"[MetadataSync] {logLabel} failed writing store '{rootPath}': {ex.Message}"); + Console.WriteLine($"[MetadataSync] {context.LogLabel} failed writing store '{context.RootPath}': {ex.Message}"); } } @@ -2092,7 +2112,7 @@ private static async Task ExecuteStoreWriteWithRetryAsync( string operationLabel, CancellationToken ct) { - for (int attempt = 0; ; attempt++) + for (int attempt = 0; attempt <= StoreRetryDelays.Length; attempt++) { try { @@ -2111,6 +2131,8 @@ private static async Task ExecuteStoreWriteWithRetryAsync( await Task.Delay(delay, ct).ConfigureAwait(false); } } + + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, $"{operationLabel} failed after retries."); } private MetadataSyncResult? ValidateDestinationLease( @@ -2190,24 +2212,12 @@ private MetadataSyncResult ExportProjectToStoreInternal( string projectExternalId = EnsureProjectExternalId(project); DateTime now = DateTime.UtcNow; - MetaInfo? metaInfo = store.GetMetaInfo(); - if (metaInfo == null) - { - metaInfo = new MetaInfo - { - SchemaVersion = MetadataStore.CurrentSchemaVersion, - CreatedUtc = now, - LastWriteUtc = now, - WriterAppVersion = appVersion, - WriterMachineId = machineId - }; - } - else - { - metaInfo.LastWriteUtc = now; - metaInfo.WriterAppVersion = appVersion; - metaInfo.WriterMachineId = machineId; - } + MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( + store, + now, + appVersion, + machineId, + updateExistingAppVersion: true); try { @@ -2314,38 +2324,12 @@ private MetadataSyncResult ExportBackupToStoreInternal( return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); } - Backup? backup = _repo.GetBackupById(backupId); - if (backup == null) - { - Console.WriteLine($"[MetadataSync] Export skipped: backup {backupId} no longer exists."); - return new MetadataSyncResult( - MetadataSyncStatus.Success, - 0, - 0, - 0, - 0, - "Backup no longer exists; metadata export skipped."); - } + if (!TryResolveBackupExportEntities(backupId, out BackupExportEntities? entities, out MetadataSyncResult? entityFailure)) + return entityFailure!; - Project? project = _repo.GetProjectById(backup.ProjectId); - if (project == null) - { - Console.WriteLine($"[MetadataSync] Export failed: project {backup.ProjectId} not found."); - return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidStore, "Project not found."); - } - - Snapshot? snapshot = _repo.GetSnapshotById(backup.SnapshotId); - if (snapshot == null) - { - Console.WriteLine($"[MetadataSync] Export skipped: snapshot {backup.SnapshotId} no longer exists."); - return new MetadataSyncResult( - MetadataSyncStatus.Success, - 0, - 0, - 0, - 0, - "Snapshot no longer exists; metadata export skipped."); - } + Backup backup = entities!.Backup; + Project project = entities.Project; + Snapshot snapshot = entities.Snapshot; MetadataSyncResult? destinationFailure = ValidateDestinationLease( rootPath, appVersion, machineId, destinationLease); @@ -2379,78 +2363,29 @@ private MetadataSyncResult ExportBackupToStoreInternal( string backupExternalId = EnsureBackupExternalId(backup); DateTime now = DateTime.UtcNow; - MetaInfo? metaInfo = store.GetMetaInfo(); - if (metaInfo == null) - { - metaInfo = new MetaInfo - { - SchemaVersion = MetadataStore.CurrentSchemaVersion, - CreatedUtc = now, - LastWriteUtc = now, - WriterAppVersion = appVersion, - WriterMachineId = machineId - }; - } - else - { - metaInfo.LastWriteUtc = now; - metaInfo.WriterAppVersion = appVersion; - metaInfo.WriterMachineId = machineId; - } - - int exportedProjects = 0; - int exportedSnapshots = 0; - int exportedBackups = 0; - bool backfilled = forceBackfill || !store.HasProject(projectExternalId); + MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( + store, + now, + appVersion, + machineId, + updateExistingAppVersion: true); + BackupExportCounts counts; try { if (!activeLease.IsOwner) return LostLeaseFailure(); - store.ExecuteWriteBatch(() => - { - store.UpsertMetaInfo(metaInfo); - if (backfilled) - { - (int snapshots, int backups) = ExportProjectHistory(store, project, projectExternalId, now, machineId); - exportedProjects = 1; - exportedSnapshots = snapshots; - exportedBackups = backups; - } - else - { - store.UpsertProject(new MetaProject - { - ExternalId = projectExternalId, - Name = project.Name, - Preset = project.Preset, - RootPathHint = project.RootPath, - CreatedUtc = project.CreatedUtc, - SettingsJson = BuildProjectSettingsJson(project), - UpdatedUtc = now - }); - store.UpsertSnapshot(new MetaSnapshot - { - ExternalId = snapshotExternalId, - ProjectExternalId = projectExternalId, - CreatedUtc = snapshot.CreatedUtc, - FileCount = snapshot.FileCount, - TotalBytes = snapshot.TotalBytes, - DiffAdded = snapshot.DiffAdded, - DiffModified = snapshot.DiffModified, - DiffDeleted = snapshot.DiffDeleted, - DiffNetBytes = snapshot.DiffNetBytes, - DiffTopPathsJson = string.IsNullOrWhiteSpace(snapshot.DiffTopPathsJson) ? "[]" : snapshot.DiffTopPathsJson - }); - store.UpsertBackup(CreateMetaBackup( - backup, - backupExternalId, - projectExternalId, - snapshotExternalId, - machineId)); - exportedBackups = 1; - } - }); + counts = WriteBackupExport( + store, + metaInfo, + entities, + new BackupExportWriteContext( + projectExternalId, + snapshotExternalId, + backupExternalId, + now, + machineId, + forceBackfill)); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { @@ -2460,13 +2395,13 @@ private MetadataSyncResult ExportBackupToStoreInternal( var exportResult = new MetadataSyncResult( MetadataSyncStatus.Success, - exportedProjects, - exportedSnapshots, - exportedBackups, + counts.Projects, + counts.Snapshots, + counts.Backups, 0, string.Empty); - Console.WriteLine(backfilled - ? $"[MetadataSync] Export complete (backfill) for project '{project.Name}' to '{storeRoot}': snapshots={exportedSnapshots}, backups={exportedBackups}." + Console.WriteLine(counts.Backfilled + ? $"[MetadataSync] Export complete (backfill) for project '{project.Name}' to '{storeRoot}': snapshots={counts.Snapshots}, backups={counts.Backups}." : $"[MetadataSync] Export complete for backup {backupId} to '{storeRoot}'."); LogStoreCounts(store); if (useDeferredStore) @@ -2479,6 +2414,109 @@ private MetadataSyncResult ExportBackupToStoreInternal( return exportResult; } + private bool TryResolveBackupExportEntities( + int backupId, + out BackupExportEntities? entities, + out MetadataSyncResult? failure) + { + entities = null; + Backup? backup = _repo.GetBackupById(backupId); + if (backup is null) + { + Console.WriteLine($"[MetadataSync] Export skipped: backup {backupId} no longer exists."); + failure = SuccessfulSkip("Backup no longer exists; metadata export skipped."); + return false; + } + + Project? project = _repo.GetProjectById(backup.ProjectId); + if (project is null) + { + Console.WriteLine($"[MetadataSync] Export failed: project {backup.ProjectId} not found."); + failure = MetadataSyncResult.Failure(MetadataSyncStatus.InvalidStore, "Project not found."); + return false; + } + + Snapshot? snapshot = _repo.GetSnapshotById(backup.SnapshotId); + if (snapshot is null) + { + Console.WriteLine($"[MetadataSync] Export skipped: snapshot {backup.SnapshotId} no longer exists."); + failure = SuccessfulSkip("Snapshot no longer exists; metadata export skipped."); + return false; + } + + entities = new BackupExportEntities(backup, project, snapshot); + failure = null; + return true; + } + + private static MetadataSyncResult SuccessfulSkip(string message) => + new(MetadataSyncStatus.Success, 0, 0, 0, 0, message); + + private BackupExportCounts WriteBackupExport( + MetadataStore store, + MetaInfo metaInfo, + BackupExportEntities entities, + BackupExportWriteContext context) + { + int exportedProjects = 0; + int exportedSnapshots = 0; + int exportedBackups = 0; + bool backfilled = context.ForceBackfill || !store.HasProject(context.ProjectExternalId); + + store.ExecuteWriteBatch(() => + { + store.UpsertMetaInfo(metaInfo); + if (backfilled) + { + (int snapshots, int backups) = ExportProjectHistory( + store, + entities.Project, + context.ProjectExternalId, + context.Now, + context.MachineId); + exportedProjects = 1; + exportedSnapshots = snapshots; + exportedBackups = backups; + return; + } + + store.UpsertProject(new MetaProject + { + ExternalId = context.ProjectExternalId, + Name = entities.Project.Name, + Preset = entities.Project.Preset, + RootPathHint = entities.Project.RootPath, + CreatedUtc = entities.Project.CreatedUtc, + SettingsJson = BuildProjectSettingsJson(entities.Project), + UpdatedUtc = context.Now + }); + store.UpsertSnapshot(new MetaSnapshot + { + ExternalId = context.SnapshotExternalId, + ProjectExternalId = context.ProjectExternalId, + CreatedUtc = entities.Snapshot.CreatedUtc, + FileCount = entities.Snapshot.FileCount, + TotalBytes = entities.Snapshot.TotalBytes, + DiffAdded = entities.Snapshot.DiffAdded, + DiffModified = entities.Snapshot.DiffModified, + DiffDeleted = entities.Snapshot.DiffDeleted, + DiffNetBytes = entities.Snapshot.DiffNetBytes, + DiffTopPathsJson = string.IsNullOrWhiteSpace(entities.Snapshot.DiffTopPathsJson) + ? "[]" + : entities.Snapshot.DiffTopPathsJson + }); + store.UpsertBackup(CreateMetaBackup( + entities.Backup, + context.BackupExternalId, + context.ProjectExternalId, + context.SnapshotExternalId, + context.MachineId)); + exportedBackups = 1; + }); + + return new BackupExportCounts(exportedProjects, exportedSnapshots, exportedBackups, backfilled); + } + public static void ExportBackupTombstoneToStore( string rootPath, string backupExternalId, @@ -2491,8 +2529,8 @@ public static void ExportBackupTombstoneToStore( backupExternalId, appVersion, machineId, - CancellationToken.None, - leaseOwnerId) + leaseOwnerId, + CancellationToken.None) .GetAwaiter().GetResult(); } @@ -2501,8 +2539,8 @@ public static async Task ExportBackupTombstoneToStoreAsync( string backupExternalId, string appVersion, string machineId, - CancellationToken ct = default, - string? leaseOwnerId = null) + string? leaseOwnerId = null, + CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(backupExternalId)) return; @@ -2514,6 +2552,14 @@ public static async Task ExportBackupTombstoneToStoreAsync( await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); var leaseService = new RepositoryLeaseService(); string ownerId = leaseOwnerId ?? CreateCompatibilityInstallationId(machineId); + var context = new TombstoneExportContext( + rootPath, + BackupEntityType, + [backupExternalId], + machineId, + "Backup tombstone export", + appVersion, + ownerId); RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( rootPath, CreateLeaseRequest(ownerId, machineId, "backup-tombstone-export", appVersion)); @@ -2525,25 +2571,12 @@ public static async Task ExportBackupTombstoneToStoreAsync( } using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; - TimeSpan[] retryDelays = - [ - TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5) - ]; - - for (int attempt = 0; attempt <= retryDelays.Length; attempt++) + for (int attempt = 0; attempt <= StoreRetryDelays.Length; attempt++) { try { ExportBackupTombstoneInternal( - rootPath, - backupExternalId, - appVersion, - machineId, - ownerId, + context, leaseService, destinationLease, useDeferredStore); @@ -2551,20 +2584,16 @@ public static async Task ExportBackupTombstoneToStoreAsync( } catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) { - if (attempt >= retryDelays.Length) + if (attempt >= StoreRetryDelays.Length) { Console.WriteLine($"[MetadataSync] Tombstone export failed after retries: {ex.Message}"); TryExportBackupTombstoneToDeferred( - rootPath, - backupExternalId, - appVersion, - machineId, - ownerId, + context, leaseService); return; } - TimeSpan delay = retryDelays[attempt]; + TimeSpan delay = StoreRetryDelays[attempt]; Console.WriteLine($"[MetadataSync] Tombstone store locked; retrying in {delay.TotalMilliseconds:0}ms."); await Task.Delay(delay, ct).ConfigureAwait(false); } @@ -2599,20 +2628,20 @@ private static StringComparer GetPathComparer() => : StringComparer.Ordinal; private static void TryExportBackupTombstoneToDeferred( - string rootPath, - string backupExternalId, - string appVersion, - string machineId, - string leaseOwnerId, + TombstoneExportContext context, RepositoryLeaseService leaseService) { try { - string deferredRoot = GetDeferredExportRoot(rootPath); + string deferredRoot = GetDeferredExportRoot(context.RootPath); Directory.CreateDirectory(deferredRoot); RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( deferredRoot, - CreateLeaseRequest(leaseOwnerId, machineId, "deferred-backup-tombstone-export", appVersion)); + CreateLeaseRequest( + context.LeaseOwnerId, + context.MachineId, + "deferred-backup-tombstone-export", + context.AppVersion)); if (!leaseResult.Acquired) return; @@ -2626,8 +2655,8 @@ private static void TryExportBackupTombstoneToDeferred( MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: true); if (!lease.IsOwner) @@ -2635,50 +2664,37 @@ private static void TryExportBackupTombstoneToDeferred( store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); - AddTombstones(store, [backupExternalId], BackupEntityType, now, machineId); + AddTombstones(store, context.ExternalIds, context.EntityType, now, context.MachineId); }); - Console.WriteLine($"[MetadataSync] Tombstone export deferred locally for '{rootPath}'."); + Console.WriteLine($"[MetadataSync] Tombstone export deferred locally for '{context.RootPath}'."); } catch (Exception ex) { - Console.WriteLine($"[MetadataSync] Tombstone defer failed for '{rootPath}': {ex.Message}"); + Console.WriteLine($"[MetadataSync] Tombstone defer failed for '{context.RootPath}': {ex.Message}"); } } private static void ExportBackupTombstoneInternal( - string rootPath, - string backupExternalId, - string appVersion, - string machineId, - string leaseOwnerId, + TombstoneExportContext context, RepositoryLeaseService leaseService, RepositoryLeaseHandle? destinationLease, bool useDeferredStore) { - if (destinationLease is not null) - { - if (!destinationLease.IsOwner) - return; - if (HasDeferredExport(rootPath) && - !TryFlushDeferredExport( - rootPath, - appVersion, - machineId, - leaseOwnerId, - leaseService)) - { - return; - } - } + if (!CanWriteTombstoneDestination(context, leaseService, destinationLease)) + return; - string storeRoot = useDeferredStore ? GetDeferredExportRoot(rootPath) : rootPath; + string storeRoot = useDeferredStore ? GetDeferredExportRoot(context.RootPath) : context.RootPath; if (useDeferredStore) Directory.CreateDirectory(storeRoot); RepositoryLeaseAcquireResult? deferredLeaseResult = useDeferredStore ? leaseService.TryAcquire( storeRoot, - CreateLeaseRequest(leaseOwnerId, machineId, "deferred-backup-tombstone-export", appVersion)) + CreateLeaseRequest( + context.LeaseOwnerId, + context.MachineId, + "deferred-backup-tombstone-export", + context.AppVersion)) : null; using RepositoryLeaseHandle? deferredLease = deferredLeaseResult?.Handle; RepositoryLeaseHandle? activeLease = destinationLease ?? deferredLease; @@ -2694,7 +2710,7 @@ private static void ExportBackupTombstoneInternal( } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { - Console.WriteLine($"[MetadataSync] Tombstone export failed: store init error at '{rootPath}': {ex.Message}"); + Console.WriteLine($"[MetadataSync] Tombstone export failed: store init error at '{context.RootPath}': {ex.Message}"); return; } @@ -2702,8 +2718,8 @@ private static void ExportBackupTombstoneInternal( MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: true); try @@ -2713,17 +2729,35 @@ private static void ExportBackupTombstoneInternal( store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); - AddTombstones(store, [backupExternalId], BackupEntityType, now, machineId); + AddTombstones(store, context.ExternalIds, context.EntityType, now, context.MachineId); }); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { - Console.WriteLine($"[MetadataSync] Tombstone export failed writing store '{rootPath}': {ex.Message}"); + Console.WriteLine($"[MetadataSync] Tombstone export failed writing store '{context.RootPath}': {ex.Message}"); return; } if (useDeferredStore) - Console.WriteLine($"[MetadataSync] Tombstone export queued locally for '{rootPath}'."); + Console.WriteLine($"[MetadataSync] Tombstone export queued locally for '{context.RootPath}'."); + } + + private static bool CanWriteTombstoneDestination( + TombstoneExportContext context, + RepositoryLeaseService leaseService, + RepositoryLeaseHandle? destinationLease) + { + if (destinationLease is null) + return true; + if (!destinationLease.IsOwner) + return false; + return !HasDeferredExport(context.RootPath) || + TryFlushDeferredExport( + context.RootPath, + context.AppVersion, + context.MachineId, + context.LeaseOwnerId, + leaseService); } private static string GetMetaDir(string rootPath) => diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs index df495d3b..f543f7a3 100644 --- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -95,7 +95,7 @@ public RepositoryLeaseService( throw new ArgumentOutOfRangeException(nameof(clockSkewTolerance)); } - public string GetDatabasePath(string rootPath) => + public static string GetDatabasePath(string rootPath) => Path.Combine(GetMetadataDirectory(rootPath), CoordinationDatabaseName); public RepositoryLeaseInspection Inspect(string rootPath) diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.cs b/src/VaultSync.UI/ViewModels/AppViewModel.cs index 0a57976c..e353b104 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.cs @@ -120,7 +120,7 @@ public sealed record DestinationProbeSummary( private readonly BackupService _backupService; private readonly NetworkMountService _networkMountService; private readonly MetadataSyncService _metadataSyncService; - private readonly IInstallationIdentityProvider _installationIdentityProvider; + private readonly InstallationIdentityService _installationIdentityProvider; private readonly CredentialVault _credentialVault; private readonly ProjectEncryptionEnrollmentService _projectEncryptionEnrollmentService; private readonly INotificationService _notificationService; diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs index 22edd6ca..b4653388 100644 --- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -39,7 +39,7 @@ public void Inspect_MissingCoordinationDatabaseIsAvailableAndReadOnly() RepositoryLeaseInspection inspection = service.Inspect(root.Path); Assert.Equal(RepositoryLeaseState.Available, inspection.State); - Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + Assert.False(File.Exists(RepositoryLeaseService.GetDatabasePath(root.Path))); } [Fact] @@ -172,7 +172,7 @@ public void TryAcquire_InvalidInstallationIdentityFailsWithoutCreatingDatabase(s CreateRequest("metadata-export", identity)); Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, result.Status); - Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + Assert.False(File.Exists(RepositoryLeaseService.GetDatabasePath(root.Path))); } [Theory] @@ -195,7 +195,7 @@ public void TryAcquire_InvalidRequestFieldsFailClosed(string invalidField) RepositoryLeaseAcquireResult result = service.TryAcquire(root.Path, request); Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, result.Status); - Assert.False(File.Exists(service.GetDatabasePath(root.Path))); + Assert.False(File.Exists(RepositoryLeaseService.GetDatabasePath(root.Path))); } [Fact] @@ -203,7 +203,7 @@ public void Inspect_ExistingDatabaseWithoutLeaseSchemaIsInvalid() { using var root = new TempDirectory(); var service = new RepositoryLeaseService(); - string databasePath = service.GetDatabasePath(root.Path); + string databasePath = RepositoryLeaseService.GetDatabasePath(root.Path); Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) { @@ -246,7 +246,7 @@ public void Inspect_MalformedLeaseFailsClosed() using RepositoryLeaseHandle handle = AssertAcquired(service.TryAcquire(root.Path, CreateRequest("metadata-export"))); handle.Dispose(); - using (var connection = new SqliteConnection($"Data Source={service.GetDatabasePath(root.Path)}")) + using (var connection = new SqliteConnection($"Data Source={RepositoryLeaseService.GetDatabasePath(root.Path)}")) { connection.Open(); connection.Execute( From 96ac56a948dd9e045f754b3d5057d7e03257699b Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:05:31 +0200 Subject: [PATCH 15/54] Consolidate network mount parsing --- .../Services/NetworkMountService.cs | 292 +++++++----------- .../VaultSync.Core.Tests/DestinationTests.cs | 49 +++ 2 files changed, 159 insertions(+), 182 deletions(-) diff --git a/src/VaultSync.Core/Services/NetworkMountService.cs b/src/VaultSync.Core/Services/NetworkMountService.cs index bc5c3bc3..4adb85d0 100644 --- a/src/VaultSync.Core/Services/NetworkMountService.cs +++ b/src/VaultSync.Core/Services/NetworkMountService.cs @@ -12,6 +12,8 @@ public sealed class NetworkMountService { private const string SmbScheme = "smb://"; + private sealed record MacMountEntry(string Source, string MountPoint, string RawLine); + private readonly Func _passwordResolver; public NetworkMountService() @@ -343,18 +345,7 @@ private DestinationResolution MountMacShare( if (TryGetMountedSharePath(shareHost, shareName, mountPoint, out string? existingMount)) { - mountPoint = existingMount; - Log($"Share already mounted for '{DisplayName(dest)}' at '{mountPoint}'."); - if (!IsSmbfsMountPoint(mountPoint, out string? mountLine)) - { - return DestinationResolution.CreateFailure(dest, $"Mount point '{mountPoint}' is not an SMB mount."); - } - if (!string.IsNullOrWhiteSpace(mountLine)) - { - Log($"SMB mount detected: {mountLine}"); - } - string effectivePath = AppendShareSubPath(mountPoint, shareSubPath); - return CreateSuccessWithKeepAlive(dest, effectivePath, mounted: false, $"Mounted {DisplayName(dest)}"); + return CreateMacMountResolution(dest, existingMount, shareSubPath, mountedByUs: false); } // Only unlock the native credential when a new mount is actually needed. @@ -404,34 +395,13 @@ private DestinationResolution MountMacShare( Log($"mount_smbfs failed for '{DisplayName(dest)}': {sanitized.Trim()}"); if (TryGetMountedSharePath(shareHost, shareName, mountPoint, out string? existingMountAfterFail)) { - mountPoint = existingMountAfterFail; - Log($"Share already mounted for '{DisplayName(dest)}' at '{mountPoint}'."); - if (!IsSmbfsMountPoint(mountPoint, out string? mountLine)) - { - return DestinationResolution.CreateFailure(dest, $"Mount point '{mountPoint}' is not an SMB mount."); - } - if (!string.IsNullOrWhiteSpace(mountLine)) - { - Log($"SMB mount detected: {mountLine}"); - } - string effectivePath = AppendShareSubPath(mountPoint, shareSubPath); - return CreateSuccessWithKeepAlive(dest, effectivePath, mounted: false, $"Mounted {DisplayName(dest)}"); + return CreateMacMountResolution(dest, existingMountAfterFail, shareSubPath, mountedByUs: false); } return DestinationResolution.CreateFailure(dest, $"Mount failed for {DisplayName(dest)}: {sanitized}".Trim()); } - Log($"Mounted '{DisplayName(dest)}' at '{mountPoint}'."); - if (!IsSmbfsMountPoint(mountPoint, out string? mountInfo)) - { - return DestinationResolution.CreateFailure(dest, $"Mount point '{mountPoint}' is not an SMB mount."); - } - if (!string.IsNullOrWhiteSpace(mountInfo)) - { - Log($"SMB mount detected: {mountInfo}"); - } - string finalPath = AppendShareSubPath(mountPoint, shareSubPath); - return CreateSuccessWithKeepAlive(dest, finalPath, mounted: true, $"Mounted {DisplayName(dest)}"); + return CreateMacMountResolution(dest, mountPoint, shareSubPath, mountedByUs: true); } catch (Exception ex) { @@ -439,6 +409,27 @@ private DestinationResolution MountMacShare( } } + private static DestinationResolution CreateMacMountResolution( + BackupDestination destination, + string mountPoint, + string shareSubPath, + bool mountedByUs) + { + Log($"Share mounted for '{DisplayName(destination)}' at '{mountPoint}'."); + if (!IsSmbfsMountPoint(mountPoint, out string? mountLine)) + return DestinationResolution.CreateFailure(destination, $"Mount point '{mountPoint}' is not an SMB mount."); + + if (!string.IsNullOrWhiteSpace(mountLine)) + Log($"SMB mount detected: {mountLine}"); + + string effectivePath = AppendShareSubPath(mountPoint, shareSubPath); + return CreateSuccessWithKeepAlive( + destination, + effectivePath, + mountedByUs, + $"Mounted {DisplayName(destination)}"); + } + private static string SanitizeMountError(string stderr, string? password, string share, string shareDisplay) { string sanitized = stderr ?? string.Empty; @@ -473,56 +464,27 @@ private static bool TryGetMountedSharePath(string host, string share, string mou if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(share)) return false; + if (!TryReadMacSmbMounts(out IReadOnlyList mounts)) + return false; + try { - var psi = new ProcessStartInfo + foreach (MacMountEntry mount in mounts) { - FileName = "/sbin/mount", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi); - if (proc is null) - return false; - - proc.WaitForExit(3_000); - string output = proc.StandardOutput.ReadToEnd(); - if (string.IsNullOrWhiteSpace(output)) - return false; - - string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (string line in lines) - { - if (!line.Contains("smbfs", StringComparison.OrdinalIgnoreCase)) - continue; - - int onIndex = line.IndexOf(" on ", StringComparison.OrdinalIgnoreCase); - if (onIndex <= 0) - continue; - - string source = line.Substring(0, onIndex).Trim(); - string rest = line.Substring(onIndex + 4); - string mountedAt = rest.Split(" (", StringSplitOptions.None)[0].Trim(); - if (string.IsNullOrWhiteSpace(mountedAt)) - continue; - if (!string.IsNullOrWhiteSpace(mountPoint) && - string.Equals(mountedAt, mountPoint, StringComparison.OrdinalIgnoreCase)) + string.Equals(mount.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase)) { - mountedPath = mountedAt; + mountedPath = mount.MountPoint; return true; } - if (!TryParseShare(source, out string? mountedHost, out string? mountedShare)) + if (!TryParseShare(mount.Source, out string? mountedHost, out string? mountedShare)) continue; if (string.Equals(host, mountedHost, StringComparison.OrdinalIgnoreCase) && string.Equals(share, mountedShare, StringComparison.OrdinalIgnoreCase)) { - mountedPath = mountedAt; + mountedPath = mount.MountPoint; return true; } } @@ -541,44 +503,16 @@ private static bool IsSmbfsMountPoint(string mountPoint, out string? mountLine) if (!OperatingSystem.IsMacOS() || string.IsNullOrWhiteSpace(mountPoint)) return false; + if (!TryReadMacSmbMounts(out IReadOnlyList mounts)) + return false; + try { - var psi = new ProcessStartInfo - { - FileName = "/sbin/mount", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi); - if (proc is null) - return false; - - proc.WaitForExit(3_000); - string output = proc.StandardOutput.ReadToEnd(); - if (string.IsNullOrWhiteSpace(output)) - return false; - - string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (string line in lines) + foreach (MacMountEntry mount in mounts) { - if (!line.Contains("smbfs", StringComparison.OrdinalIgnoreCase)) - continue; - - int onIndex = line.IndexOf(" on ", StringComparison.OrdinalIgnoreCase); - if (onIndex <= 0) - continue; - - string rest = line[(onIndex + 4)..]; - string mountedAt = rest.Split(" (", StringSplitOptions.None)[0].Trim(); - if (string.IsNullOrWhiteSpace(mountedAt)) - continue; - - if (string.Equals(mountedAt, mountPoint, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(mount.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase)) { - mountLine = line; + mountLine = mount.RawLine; return true; } } @@ -722,8 +656,14 @@ private static bool IsAccessibleDirectory(string path, out string? error) private static bool TryParseShare(string raw, out string host, out string share) { - host = string.Empty; + return TryParseShareWithSubpath(raw, out host, out share, out _); + } + + internal static bool TryParseShareWithSubpath(string raw, out string host, out string share, out string subPath) + { + host = string.Empty; share = string.Empty; + subPath = string.Empty; try { @@ -743,7 +683,7 @@ private static bool TryParseShare(string raw, out string host, out string share) string[] parts = raw.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { - host = parts[0]; + host = parts[0]; share = parts[1]; if (host.Contains('@')) @@ -756,6 +696,11 @@ private static bool TryParseShare(string raw, out string host, out string share) host = host.Split(':').First(); } + if (parts.Length > 2) + { + subPath = string.Join('/', parts.Skip(2)); + } + return true; } } @@ -767,57 +712,68 @@ private static bool TryParseShare(string raw, out string host, out string share) return false; } - private static bool TryParseShareWithSubpath(string raw, out string host, out string share, out string subPath) + private static bool TryReadMacSmbMounts(out IReadOnlyList mounts) { - host = string.Empty; - share = string.Empty; - subPath = string.Empty; + mounts = []; + if (!OperatingSystem.IsMacOS()) + return false; try { - if (raw.StartsWith(SmbScheme, StringComparison.OrdinalIgnoreCase)) - { - raw = raw[SmbScheme.Length..]; - } - else if (raw.StartsWith(@"\\")) - { - raw = raw.TrimStart('\\'); - } - else if (raw.StartsWith(@"//")) - { - raw = raw.TrimStart('/'); - } - - string[] parts = raw.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - host = parts[0]; - share = parts[1]; - - if (host.Contains('@')) - { - host = host.Split('@').Last(); - } - - if (host.Contains(':')) - { - host = host.Split(':').First(); - } + ProcessStartInfo psi = CreateHiddenProcessStartInfo("/sbin/mount"); + using var process = Process.Start(psi); + if (process is null) + return false; - if (parts.Length > 2) - { - subPath = string.Join('/', parts.Skip(2)); - } + process.WaitForExit(3_000); + string output = process.StandardOutput.ReadToEnd(); + if (string.IsNullOrWhiteSpace(output)) + return false; - return true; - } + string[] lines = output.Split( + '\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + mounts = lines + .Select(ParseMacSmbMount) + .Where(entry => entry is not null) + .Cast() + .ToList(); + return true; } catch { return false; } + } - return false; + private static MacMountEntry? ParseMacSmbMount(string line) + { + return TryParseMacSmbMountLine(line, out string source, out string mountPoint) + ? new MacMountEntry(source, mountPoint, line) + : null; + } + + internal static bool TryParseMacSmbMountLine( + string line, + out string source, + out string mountPoint) + { + source = string.Empty; + mountPoint = string.Empty; + if (string.IsNullOrWhiteSpace(line) || + !line.Contains("smbfs", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int onIndex = line.IndexOf(" on ", StringComparison.OrdinalIgnoreCase); + if (onIndex <= 0) + return false; + + source = line[..onIndex].Trim(); + string rest = line[(onIndex + 4)..]; + mountPoint = rest.Split(" (", StringSplitOptions.None)[0].Trim(); + return !string.IsNullOrWhiteSpace(source) && !string.IsNullOrWhiteSpace(mountPoint); } private static string AppendShareSubPath(string mountPoint, string subPath) @@ -917,47 +873,19 @@ private static bool TryResolveSmbMountRoot(string path, out string mountRoot) if (string.IsNullOrWhiteSpace(path)) return false; + if (!TryReadMacSmbMounts(out IReadOnlyList mounts)) + return false; + try { - var psi = new ProcessStartInfo - { - FileName = "/sbin/mount", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi); - if (proc is null) - return false; - - proc.WaitForExit(3_000); - string output = proc.StandardOutput.ReadToEnd(); - if (string.IsNullOrWhiteSpace(output)) - return false; - string candidate = string.Empty; - string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (string line in lines) + foreach (MacMountEntry mount in mounts) { - if (!line.Contains("smbfs", StringComparison.OrdinalIgnoreCase)) - continue; - - int onIndex = line.IndexOf(" on ", StringComparison.OrdinalIgnoreCase); - if (onIndex <= 0) - continue; - - string rest = line[(onIndex + 4)..]; - string mountedAt = rest.Split(" (", StringSplitOptions.None)[0].Trim(); - if (string.IsNullOrWhiteSpace(mountedAt)) - continue; - - if (!path.StartsWith(mountedAt, StringComparison.OrdinalIgnoreCase)) + if (!path.StartsWith(mount.MountPoint, StringComparison.OrdinalIgnoreCase)) continue; - if (mountedAt.Length > candidate.Length) - candidate = mountedAt; + if (mount.MountPoint.Length > candidate.Length) + candidate = mount.MountPoint; } if (string.IsNullOrWhiteSpace(candidate)) diff --git a/tests/VaultSync.Core.Tests/DestinationTests.cs b/tests/VaultSync.Core.Tests/DestinationTests.cs index 15c09eab..d7cc0ef7 100644 --- a/tests/VaultSync.Core.Tests/DestinationTests.cs +++ b/tests/VaultSync.Core.Tests/DestinationTests.cs @@ -11,6 +11,55 @@ namespace VaultSync.Core.Tests; public sealed class NetworkMountServiceTests { + [Theory] + [InlineData("smb://server/share/folder/child", "server", "share", "folder/child")] + [InlineData(@"\\server\share\folder", "server", "share", "folder")] + [InlineData("//user@server:445/share", "server", "share", "")] + public void TryParseShareWithSubpath_NormalizesSupportedShareForms( + string raw, + string expectedHost, + string expectedShare, + string expectedSubPath) + { + bool parsed = NetworkMountService.TryParseShareWithSubpath( + raw, + out string host, + out string share, + out string subPath); + + Assert.True(parsed); + Assert.Equal(expectedHost, host); + Assert.Equal(expectedShare, share); + Assert.Equal(expectedSubPath, subPath); + } + + [Theory] + [InlineData("//user@server/share on /Volumes/Share (smbfs, nodev, nosuid)", "//user@server/share", "/Volumes/Share")] + [InlineData("//server/share on /Users/test/VaultSync mounts/work (SMBFS)", "//server/share", "/Users/test/VaultSync mounts/work")] + public void TryParseMacSmbMountLine_ExtractsSourceAndMountPoint( + string line, + string expectedSource, + string expectedMountPoint) + { + bool parsed = NetworkMountService.TryParseMacSmbMountLine( + line, + out string source, + out string mountPoint); + + Assert.True(parsed); + Assert.Equal(expectedSource, source); + Assert.Equal(expectedMountPoint, mountPoint); + } + + [Theory] + [InlineData("")] + [InlineData("/dev/disk3 on /Volumes/Data (apfs)")] + [InlineData("smbfs without mount separator")] + public void TryParseMacSmbMountLine_RejectsUnrelatedOrMalformedLines(string line) + { + Assert.False(NetworkMountService.TryParseMacSmbMountLine(line, out _, out _)); + } + [Fact] public void PrepareDestination_WithLocalPath_ReturnsSuccessAndEffectivePath() { From c226e45cdb1da41b684b0bedd5438a6b3b2effbc Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:07:40 +0200 Subject: [PATCH 16/54] Share theme color primitives --- src/VaultSync.UI/Services/ThemeColor.cs | 52 ++++++++++++++ src/VaultSync.UI/Services/ThemeManager.cs | 70 ++++--------------- .../SettingsViewModel.ThemeEditor.cs | 16 +---- .../Controls/ContrastForegroundConverter.cs | 26 +------ 4 files changed, 67 insertions(+), 97 deletions(-) create mode 100644 src/VaultSync.UI/Services/ThemeColor.cs diff --git a/src/VaultSync.UI/Services/ThemeColor.cs b/src/VaultSync.UI/Services/ThemeColor.cs new file mode 100644 index 00000000..cb9c34da --- /dev/null +++ b/src/VaultSync.UI/Services/ThemeColor.cs @@ -0,0 +1,52 @@ +using System; +using Avalonia.Media; + +namespace VaultSync.UI.Services; + +internal static class ThemeColor +{ + private static readonly Color NearBlack = Color.Parse("#11131A"); + + public static string NormalizeHex(string? value, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + string candidate = value.Trim(); + if (!candidate.StartsWith("#", StringComparison.Ordinal)) + candidate = "#" + candidate; + + return Color.TryParse(candidate, out Color color) + ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" + : fallback; + } + + public static Color BestContrast(Color background) => + ContrastRatio(Colors.White, background) >= ContrastRatio(NearBlack, background) + ? Colors.White + : NearBlack; + + public static double ContrastRatio(Color first, Color second) + { + double firstLuminance = RelativeLuminance(first); + double secondLuminance = RelativeLuminance(second); + double lighter = Math.Max(firstLuminance, secondLuminance); + double darker = Math.Min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); + } + + private static double RelativeLuminance(Color color) + { + static double Linearize(byte channel) + { + double value = channel / 255d; + return value <= 0.04045 + ? value / 12.92 + : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + return (0.2126 * Linearize(color.R)) + + (0.7152 * Linearize(color.G)) + + (0.0722 * Linearize(color.B)); + } +} diff --git a/src/VaultSync.UI/Services/ThemeManager.cs b/src/VaultSync.UI/Services/ThemeManager.cs index c562d8fa..d8952b1f 100644 --- a/src/VaultSync.UI/Services/ThemeManager.cs +++ b/src/VaultSync.UI/Services/ThemeManager.cs @@ -256,7 +256,7 @@ private static void ApplyPaletteOverrides(Application app, string themeName, The ThemePaletteConfig palette = NormalizePalette(customTheme ?? GetDefaultCustomTheme()); bool isLightBase = string.Equals(palette.BaseTheme, ThemeLight, StringComparison.OrdinalIgnoreCase); Color accentSoft = WithAlpha(palette.Accent, isLightBase ? 0.14 : 0.24); - Color textOnAccent = BestContrast(Color.Parse(palette.Accent)); + Color textOnAccent = ThemeColor.BestContrast(Color.Parse(palette.Accent)); Color textMuted = Blend(palette.TextSecondary, palette.Background, isLightBase ? 0.45 : 0.60); Color inputBackground = Blend(palette.SurfaceAlt, palette.Background, isLightBase ? 0.45 : 0.25); Color inputBorder = Blend(palette.SurfaceAlt, palette.TextSecondary, isLightBase ? 0.35 : 0.28); @@ -303,9 +303,9 @@ private static void ClearPaletteOverrides(Application app) private static ThemePaletteConfig NormalizePalette(ThemePaletteConfig palette) { ThemePaletteConfig defaults = GetDefaultCustomTheme(); - string background = NormalizeHex(palette.Background, defaults.Background); - string surface = NormalizeHex(palette.Surface, defaults.Surface); - string surfaceAlt = NormalizeHex(palette.SurfaceAlt, defaults.SurfaceAlt); + string background = ThemeColor.NormalizeHex(palette.Background, defaults.Background); + string surface = ThemeColor.NormalizeHex(palette.Surface, defaults.Surface); + string surfaceAlt = ThemeColor.NormalizeHex(palette.SurfaceAlt, defaults.SurfaceAlt); return new ThemePaletteConfig { Name = string.IsNullOrWhiteSpace(palette.Name) ? defaults.Name : palette.Name.Trim(), @@ -316,35 +316,21 @@ private static ThemePaletteConfig NormalizePalette(ThemePaletteConfig palette) Background = background, Surface = surface, SurfaceAlt = surfaceAlt, - Accent = NormalizeHex(palette.Accent, defaults.Accent), + Accent = ThemeColor.NormalizeHex(palette.Accent, defaults.Accent), TextPrimary = EnsureReadableText( - NormalizeHex(palette.TextPrimary, defaults.TextPrimary), + ThemeColor.NormalizeHex(palette.TextPrimary, defaults.TextPrimary), [background, surface, surfaceAlt], 4.5), TextSecondary = EnsureReadableText( - NormalizeHex(palette.TextSecondary, defaults.TextSecondary), + ThemeColor.NormalizeHex(palette.TextSecondary, defaults.TextSecondary), [background, surface, surfaceAlt], 3.0), - Success = NormalizeHex(palette.Success, defaults.Success), - Warning = NormalizeHex(palette.Warning, defaults.Warning), - Danger = NormalizeHex(palette.Danger, defaults.Danger) + Success = ThemeColor.NormalizeHex(palette.Success, defaults.Success), + Warning = ThemeColor.NormalizeHex(palette.Warning, defaults.Warning), + Danger = ThemeColor.NormalizeHex(palette.Danger, defaults.Danger) }; } - private static string NormalizeHex(string? value, string fallback) - { - if (string.IsNullOrWhiteSpace(value)) - return fallback; - - string candidate = value.Trim(); - if (!candidate.StartsWith("#", StringComparison.Ordinal)) - candidate = "#" + candidate; - - return Color.TryParse(candidate, out Color color) - ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" - : fallback; - } - private static Color Blend(string foregroundHex, string backgroundHex, double amount) { var foreground = Color.Parse(foregroundHex); @@ -378,47 +364,15 @@ private static string EnsureReadableText(string preferredHex, string[] backgroun { Color preferred = Color.Parse(preferredHex); Color[] backgrounds = backgroundHexes.Select(Color.Parse).ToArray(); - if (backgrounds.All(background => ContrastRatio(preferred, background) >= minimumRatio)) + if (backgrounds.All(background => ThemeColor.ContrastRatio(preferred, background) >= minimumRatio)) return preferredHex; Color best = new[] { Colors.White, Color.Parse("#11131A") } - .OrderByDescending(candidate => backgrounds.Min(background => ContrastRatio(candidate, background))) + .OrderByDescending(candidate => backgrounds.Min(background => ThemeColor.ContrastRatio(candidate, background))) .First(); return $"#{best.R:X2}{best.G:X2}{best.B:X2}"; } - private static Color BestContrast(Color background) - { - Color white = Colors.White; - Color nearBlack = Color.Parse("#11131A"); - return ContrastRatio(white, background) >= ContrastRatio(nearBlack, background) - ? white - : nearBlack; - } - - private static double ContrastRatio(Color first, Color second) - { - double firstLuminance = RelativeLuminance(first); - double secondLuminance = RelativeLuminance(second); - double lighter = Math.Max(firstLuminance, secondLuminance); - double darker = Math.Min(firstLuminance, secondLuminance); - return (lighter + 0.05) / (darker + 0.05); - } - - private static double RelativeLuminance(Color color) - { - static double Linearize(byte channel) - { - double value = channel / 255d; - return value <= 0.04045 - ? value / 12.92 - : Math.Pow((value + 0.055) / 1.055, 2.4); - } - - return (0.2126 * Linearize(color.R)) - + (0.7152 * Linearize(color.G)) - + (0.0722 * Linearize(color.B)); - } private static void SetColorOverride(Application app, string key, string hex) { diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.ThemeEditor.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.ThemeEditor.cs index e1ee56f1..bc2e45d1 100644 --- a/src/VaultSync.UI/ViewModels/SettingsViewModel.ThemeEditor.cs +++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.ThemeEditor.cs @@ -57,7 +57,7 @@ public string Hex get => _hex; set { - string normalized = NormalizeHex(value, _hex); + string normalized = ThemeColor.NormalizeHex(value, _hex); if (_hex == normalized) return; @@ -85,20 +85,6 @@ public bool IsSelected public Color SwatchColor => Color.Parse(_hex); public IBrush SwatchBrush => _swatchBrush; - private static string NormalizeHex(string? value, string fallback) - { - if (string.IsNullOrWhiteSpace(value)) - return fallback; - - string candidate = value.Trim(); - if (!candidate.StartsWith("#", StringComparison.Ordinal)) - candidate = "#" + candidate; - - return Color.TryParse(candidate, out Color color) - ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" - : fallback; - } - } public sealed class ThemePresetOptionViewModel diff --git a/src/VaultSync.UI/Views/Controls/ContrastForegroundConverter.cs b/src/VaultSync.UI/Views/Controls/ContrastForegroundConverter.cs index f5842454..3556867d 100644 --- a/src/VaultSync.UI/Views/Controls/ContrastForegroundConverter.cs +++ b/src/VaultSync.UI/Views/Controls/ContrastForegroundConverter.cs @@ -3,6 +3,7 @@ using Avalonia.Data.Converters; using Avalonia.Media; using Avalonia.Media.Immutable; +using VaultSync.UI.Services; namespace VaultSync.UI.Views.Controls; @@ -16,7 +17,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture if (!TryGetColor(value, out Color background)) return LightForeground; - return ContrastRatio(Colors.White, background) >= ContrastRatio(Color.Parse("#11131A"), background) + return ThemeColor.BestContrast(background) == Colors.White ? LightForeground : DarkForeground; } @@ -39,27 +40,4 @@ private static bool TryGetColor(object? value, out Color color) return false; } - private static double ContrastRatio(Color first, Color second) - { - double firstLuminance = RelativeLuminance(first); - double secondLuminance = RelativeLuminance(second); - double lighter = Math.Max(firstLuminance, secondLuminance); - double darker = Math.Min(firstLuminance, secondLuminance); - return (lighter + 0.05) / (darker + 0.05); - } - - private static double RelativeLuminance(Color color) - { - static double Linearize(byte channel) - { - double value = channel / 255d; - return value <= 0.04045 - ? value / 12.92 - : Math.Pow((value + 0.055) / 1.055, 2.4); - } - - return (0.2126 * Linearize(color.R)) - + (0.7152 * Linearize(color.G)) - + (0.0722 * Linearize(color.B)); - } } From 87366ef9a6999cb5654da1a98ebdf2a40bc8bbc9 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:08:02 +0200 Subject: [PATCH 17/54] Record the first simplification pass --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07aeb5d7..ff035e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [BUG-18099] Serviced the .NET 10 baseline to SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft packages, with CI auditing real self-contained publishes and release artifacts for every supported runtime identifier. - [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. - [VS-1877] Made unavailable-destination metadata queues fail closed: queued metadata can initialize an empty destination once, while an existing destination is preserved for explicit merge review. +- [VS-1880] Consolidated metadata export orchestration, SMB mount parsing, mounted-share validation, theme color normalization, and contrast calculations behind focused shared primitives with regression coverage. ### Fixed - [BUG-18100] Restored `Dev` as the permanent integration branch at the `1.8.6` Stable commit and disabled automatic head-branch deletion so Stable promotion cannot remove it again. From 37e9848c6136b08e6e6e8f14ac39e9040efbe43c Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:16:01 +0200 Subject: [PATCH 18/54] Allow unreleased changelog development --- scripts/release_readiness_gate.ps1 | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/release_readiness_gate.ps1 b/scripts/release_readiness_gate.ps1 index b38e14be..fe4bd9bb 100644 --- a/scripts/release_readiness_gate.ps1 +++ b/scripts/release_readiness_gate.ps1 @@ -46,14 +46,17 @@ function Get-FileVersionValue { return $match.Groups[1].Value.Trim() } -function Get-ChangelogVersion { +function Get-ChangelogHeader { $line = Get-Content CHANGELOG.md | Select-Object -First 3 | Where-Object { $_ -match '^## \[(.+?)\] - (Unreleased|\d{2}\.\d{2}\.\d{4})' } | Select-Object -First 1 if (-not $line) { throw "Could not find release changelog header in CHANGELOG.md." } $match = [regex]::Match($line, '^## \[(.+?)\] - (Unreleased|\d{2}\.\d{2}\.\d{4})') - return $match.Groups[1].Value.Trim() + return [pscustomobject]@{ + version = $match.Groups[1].Value.Trim() + status = $match.Groups[2].Value.Trim() + } } function Get-WhatsNewVersion { @@ -161,7 +164,13 @@ if ([string]::IsNullOrWhiteSpace($TargetMilestone)) { $results = New-Object System.Collections.Generic.List[object] $uiVersion = Get-FileVersionValue -Path "src/VaultSync.UI/VaultSync.UI.csproj" -Pattern '([^<]+)' $installerVersion = Get-FileVersionValue -Path "installer/VaultSyncInstaller.iss" -Pattern '#define MyAppVersion "([^"]+)"' -$changelogVersion = Get-ChangelogVersion +$changelogHeader = Get-ChangelogHeader +$changelogVersion = $changelogHeader.version +$changelogHasTargetSection = [regex]::IsMatch( + (Get-Content CHANGELOG.md -Raw), + "(?m)^## \[$([regex]::Escape($TargetVersion))\] - ") +$changelogMatchesTarget = $changelogVersion -eq $TargetVersion -or ( + $changelogHeader.status -eq "Unreleased" -and $changelogHasTargetSection) $whatsNewVersion = Get-WhatsNewVersion $releasingDoc = Get-Content docs/RELEASING.md -Raw $securityDoc = Get-Content SECURITY.md -Raw @@ -176,10 +185,10 @@ Add-CheckResult -Results $results -Code "version-installer" -Condition ($install -FailMessage "Installer version '$installerVersion' does not match target '$TargetVersion'." ` -Data @{ expected = $TargetVersion; actual = $installerVersion } -Add-CheckResult -Results $results -Code "docs-changelog" -Condition ($changelogVersion -eq $TargetVersion) ` - -PassMessage "Top changelog version is '$changelogVersion'." ` - -FailMessage "Top changelog version '$changelogVersion' does not match target '$TargetVersion'." ` - -Data @{ expected = $TargetVersion; actual = $changelogVersion } +Add-CheckResult -Results $results -Code "docs-changelog" -Condition $changelogMatchesTarget ` + -PassMessage "Changelog contains target '$TargetVersion'; top entry is '$changelogVersion' ($($changelogHeader.status))." ` + -FailMessage "Top changelog entry '$changelogVersion' ($($changelogHeader.status)) is incompatible with target '$TargetVersion'." ` + -Data @{ expected = $TargetVersion; actual = $changelogVersion; status = $changelogHeader.status } Add-CheckResult -Results $results -Code "docs-whats-new" -Condition ($whatsNewVersion -eq $TargetVersion) ` -PassMessage "Top What's New version is '$whatsNewVersion'." ` From b69287cc2a34923ce2d759e31f7f055cb8f31f7e Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:28:17 +0200 Subject: [PATCH 19/54] Clear follow-up Sonar findings --- .../Commands/DestinationCommand.cs | 2 +- .../Services/NetworkMountService.cs | 207 +++++++++++------- .../Services/RepositoryLeaseService.cs | 2 +- .../ViewModels/AppViewModel.BackupHandlers.cs | 4 +- .../AppViewModel.BackupHistoryHandlers.cs | 2 +- .../AppViewModel.ConfigurationOps.cs | 6 +- .../ViewModels/AppViewModel.RuntimeOps.cs | 4 +- .../ViewModels/SettingsViewModel.cs | 2 +- .../RepositoryLeaseServiceTests.cs | 6 +- .../VaultSync.Core.Tests/ThemeManagerTests.cs | 39 ++-- 10 files changed, 156 insertions(+), 118 deletions(-) diff --git a/src/VaultSync.CLI/Commands/DestinationCommand.cs b/src/VaultSync.CLI/Commands/DestinationCommand.cs index 08c65037..2b3667c6 100644 --- a/src/VaultSync.CLI/Commands/DestinationCommand.cs +++ b/src/VaultSync.CLI/Commands/DestinationCommand.cs @@ -82,7 +82,7 @@ private static DestinationInfo TestDestination( string message = string.IsNullOrWhiteSpace(resolution.Message) ? (reachable ? "Reachable" : "Unreachable") : resolution.Message; - mountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); return new DestinationInfo(alias, path, status, reachable, message); } catch (Exception ex) diff --git a/src/VaultSync.Core/Services/NetworkMountService.cs b/src/VaultSync.Core/Services/NetworkMountService.cs index 4adb85d0..04548a8e 100644 --- a/src/VaultSync.Core/Services/NetworkMountService.cs +++ b/src/VaultSync.Core/Services/NetworkMountService.cs @@ -51,38 +51,8 @@ public DestinationResolution PrepareDestination(BackupDestination dest, NetworkC : DestinationResolution.CreateFailure(dest, $"Destination '{alias}' is marked pre-mounted but is not accessible."); } - bool isNetwork = IsNetworkPath(normalizedPath); - if (!isNetwork) - { - if (IsMacVolumesPath(normalizedPath)) - { - if (IsAccessibleDirectory(normalizedPath, out string? accessError)) - { - Log($"Using macOS mounted volume path '{normalizedPath}'."); - return CreateSuccessWithKeepAlive(dest, normalizedPath, mounted: false, $"Using mounted volume path '{normalizedPath}'"); - } - - string detail = string.IsNullOrWhiteSpace(accessError) - ? "The mounted volume path is not accessible." - : accessError; - Log($"Mounted volume path '{normalizedPath}' failed: {detail}"); - return DestinationResolution.CreateFailure( - dest, - $"Cannot use destination '{alias}': {detail} Use a reachable /Volumes mount point, or configure the destination as smb://host/share for auto-mount."); - } - - try - { - Directory.CreateDirectory(normalizedPath); - Log($"Using local path '{normalizedPath}'."); - return CreateSuccessWithKeepAlive(dest, normalizedPath, mounted: false, $"Using local path '{normalizedPath}'"); - } - catch (Exception ex) - { - Log($"Local path '{normalizedPath}' failed: {ex.Message}"); - return DestinationResolution.CreateFailure(dest, $"Cannot use destination '{alias}': {ex.Message}"); - } - } + if (!IsNetworkPath(normalizedPath)) + return PrepareLocalDestination(dest, alias, normalizedPath); if (!dest.AutoMount) { @@ -122,7 +92,7 @@ public DestinationResolution PrepareDestination(BackupDestination dest, NetworkC return DestinationResolution.CreateFailure(dest, "Auto-mount is only supported on Windows and macOS."); } - public void Cleanup(DestinationResolution resolution) + public static void Cleanup(DestinationResolution resolution) { if (!resolution.MountedByUs || !resolution.Destination.AutoUnmount) return; @@ -138,6 +108,47 @@ public void Cleanup(DestinationResolution resolution) } } + private static DestinationResolution PrepareLocalDestination( + BackupDestination destination, + string alias, + string normalizedPath) + { + if (IsMacVolumesPath(normalizedPath)) + return PrepareMacVolumeDestination(destination, alias, normalizedPath); + + try + { + Directory.CreateDirectory(normalizedPath); + Log($"Using local path '{normalizedPath}'."); + return CreateSuccessWithKeepAlive(destination, normalizedPath, mounted: false, $"Using local path '{normalizedPath}'"); + } + catch (Exception ex) + { + Log($"Local path '{normalizedPath}' failed: {ex.Message}"); + return DestinationResolution.CreateFailure(destination, $"Cannot use destination '{alias}': {ex.Message}"); + } + } + + private static DestinationResolution PrepareMacVolumeDestination( + BackupDestination destination, + string alias, + string normalizedPath) + { + if (IsAccessibleDirectory(normalizedPath, out string? accessError)) + { + Log($"Using macOS mounted volume path '{normalizedPath}'."); + return CreateSuccessWithKeepAlive(destination, normalizedPath, mounted: false, $"Using mounted volume path '{normalizedPath}'"); + } + + string detail = string.IsNullOrWhiteSpace(accessError) + ? "The mounted volume path is not accessible." + : accessError; + Log($"Mounted volume path '{normalizedPath}' failed: {detail}"); + return DestinationResolution.CreateFailure( + destination, + $"Cannot use destination '{alias}': {detail} Use a reachable /Volumes mount point, or configure the destination as smb://host/share for auto-mount."); + } + private string? ResolvePassword(NetworkCredentialProfile? profile) { return _passwordResolver(profile); @@ -307,41 +318,13 @@ private DestinationResolution MountMacShare( string normalizedPath, NetworkCredentialProfile? profile) { - if (!TryParseShareWithSubpath(normalizedPath, out string? shareHost, out string? shareName, out string? shareSubPath)) + if (!TryParseShareWithSubpath(normalizedPath, out string shareHost, out string shareName, out string shareSubPath)) { return DestinationResolution.CreateFailure(dest, "Destination must be an smb:// or UNC path for auto-mount."); } - string mountRoot = GetMacMountRoot(); - try - { - Directory.CreateDirectory(mountRoot); - } - catch (Exception ex) - { - return DestinationResolution.CreateFailure(dest, $"Unable to create mount root '{mountRoot}': {ex.Message}"); - } - - string mountPoint = Path.Combine(mountRoot, string.IsNullOrWhiteSpace(dest.Alias) ? shareName : Slugify(dest.Alias!)); - if (!Directory.Exists(mountPoint)) - { - try - { - Directory.CreateDirectory(mountPoint); - } - catch (Exception ex) - { - string? existing = FindExistingMountPoint(shareName, mountRoot); - if (!string.IsNullOrWhiteSpace(existing)) - { - mountPoint = existing; - } - else - { - return DestinationResolution.CreateFailure(dest, $"Unable to create mount point '{mountPoint}': {ex.Message}"); - } - } - } + if (!TryPrepareMacMountPoint(dest, shareName, out string mountPoint, out string? mountError)) + return DestinationResolution.CreateFailure(dest, mountError ?? "Unable to prepare the SMB mount point."); if (TryGetMountedSharePath(shareHost, shareName, mountPoint, out string? existingMount)) { @@ -361,13 +344,68 @@ private DestinationResolution MountMacShare( ? "guest" : profile.Username; + return RunMacMount(dest, shareHost, shareName, shareSubPath, mountPoint, password, userPart); + } + + private static bool TryPrepareMacMountPoint( + BackupDestination destination, + string shareName, + out string mountPoint, + out string? error) + { + string mountRoot = GetMacMountRoot(); + mountPoint = string.Empty; + error = null; + try + { + Directory.CreateDirectory(mountRoot); + } + catch (Exception ex) + { + error = $"Unable to create mount root '{mountRoot}': {ex.Message}"; + return false; + } + + string mountName = string.IsNullOrWhiteSpace(destination.Alias) + ? shareName + : Slugify(destination.Alias); + mountPoint = Path.Combine(mountRoot, mountName); + if (Directory.Exists(mountPoint)) + return true; + + try + { + Directory.CreateDirectory(mountPoint); + return true; + } + catch (Exception ex) + { + string? existing = FindExistingMountPoint(shareName, mountRoot); + if (!string.IsNullOrWhiteSpace(existing)) + { + mountPoint = existing; + return true; + } + + error = $"Unable to create mount point '{mountPoint}': {ex.Message}"; + return false; + } + } + + private static DestinationResolution RunMacMount( + BackupDestination destination, + string shareHost, + string shareName, + string shareSubPath, + string mountPoint, + string? password, + string userPart) + { string passwordPart = string.IsNullOrWhiteSpace(password) ? string.Empty : ":" + Uri.EscapeDataString(password); - string share = $"//{userPart}{passwordPart}@{shareHost}/{shareName}"; string shareDisplay = $"//{userPart}@{shareHost}/{shareName}"; - var psi = new ProcessStartInfo { FileName = "/sbin/mount_smbfs", @@ -384,7 +422,7 @@ private DestinationResolution MountMacShare( { using var proc = Process.Start(psi); if (proc is null) - return DestinationResolution.CreateFailure(dest, "Unable to start mount_smbfs."); + return DestinationResolution.CreateFailure(destination, "Unable to start mount_smbfs."); proc.WaitForExit(10_000); @@ -392,20 +430,20 @@ private DestinationResolution MountMacShare( { string stderr = proc.StandardError.ReadToEnd(); string sanitized = SanitizeMountError(stderr, password, share, shareDisplay); - Log($"mount_smbfs failed for '{DisplayName(dest)}': {sanitized.Trim()}"); + Log($"mount_smbfs failed for '{DisplayName(destination)}': {sanitized.Trim()}"); if (TryGetMountedSharePath(shareHost, shareName, mountPoint, out string? existingMountAfterFail)) { - return CreateMacMountResolution(dest, existingMountAfterFail, shareSubPath, mountedByUs: false); + return CreateMacMountResolution(destination, existingMountAfterFail, shareSubPath, mountedByUs: false); } - return DestinationResolution.CreateFailure(dest, $"Mount failed for {DisplayName(dest)}: {sanitized}".Trim()); + return DestinationResolution.CreateFailure(destination, $"Mount failed for {DisplayName(destination)}: {sanitized}".Trim()); } - return CreateMacMountResolution(dest, mountPoint, shareSubPath, mountedByUs: true); + return CreateMacMountResolution(destination, mountPoint, shareSubPath, mountedByUs: true); } catch (Exception ex) { - return DestinationResolution.CreateFailure(dest, $"Mount failed for {DisplayName(dest)}: {ex.Message}"); + return DestinationResolution.CreateFailure(destination, $"Mount failed for {DisplayName(destination)}: {ex.Message}"); } } @@ -508,13 +546,11 @@ private static bool IsSmbfsMountPoint(string mountPoint, out string? mountLine) try { - foreach (MacMountEntry mount in mounts) + foreach (MacMountEntry mount in mounts.Where( + mount => string.Equals(mount.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase))) { - if (string.Equals(mount.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase)) - { - mountLine = mount.RawLine; - return true; - } + mountLine = mount.RawLine; + return true; } } catch @@ -688,12 +724,13 @@ internal static bool TryParseShareWithSubpath(string raw, out string host, out s if (host.Contains('@')) { - host = host.Split('@').Last(); + string[] hostParts = host.Split('@'); + host = hostParts[^1]; } if (host.Contains(':')) { - host = host.Split(':').First(); + host = host.Split(':')[0]; } if (parts.Length > 2) @@ -818,7 +855,7 @@ private static string NormalizePath(string raw, out string? error) private static string DisplayName(BackupDestination dest) { if (!string.IsNullOrWhiteSpace(dest.Alias)) - return dest.Alias!; + return dest.Alias; if (!string.IsNullOrWhiteSpace(dest.Path)) return dest.Path; return "Destination"; @@ -879,13 +916,13 @@ private static bool TryResolveSmbMountRoot(string path, out string mountRoot) try { string candidate = string.Empty; - foreach (MacMountEntry mount in mounts) + foreach (string mountPoint in mounts.Select(mount => mount.MountPoint)) { - if (!path.StartsWith(mount.MountPoint, StringComparison.OrdinalIgnoreCase)) + if (!path.StartsWith(mountPoint, StringComparison.OrdinalIgnoreCase)) continue; - if (mount.MountPoint.Length > candidate.Length) - candidate = mount.MountPoint; + if (mountPoint.Length > candidate.Length) + candidate = mountPoint; } if (string.IsNullOrWhiteSpace(candidate)) diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs index f543f7a3..ab1017a5 100644 --- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -268,7 +268,7 @@ current.Lease is null || } } - public IReadOnlyList ListEvidence(string rootPath) + public static IReadOnlyList ListEvidence(string rootPath) { string databasePath; try diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.BackupHandlers.cs b/src/VaultSync.UI/ViewModels/AppViewModel.BackupHandlers.cs index 7459215a..a4021f3a 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.BackupHandlers.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.BackupHandlers.cs @@ -284,7 +284,7 @@ private async Task OnBackupProjectRequestedAsync(ProjectBackupItem? item) { driveBlocked++; BackupsViewModel.UpdateDestinationStatus(destId, driveDecision.Message, BackupsViewModel.SeverityStatus.Warning); - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); continue; } @@ -515,7 +515,7 @@ private async Task OnBackupProjectRequestedAsync(ProjectBackupItem? item) } finally { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } } diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.BackupHistoryHandlers.cs b/src/VaultSync.UI/ViewModels/AppViewModel.BackupHistoryHandlers.cs index c2b3edc1..a75dc728 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.BackupHistoryHandlers.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.BackupHistoryHandlers.cs @@ -342,7 +342,7 @@ await Task.Run(() => if (deleteResolution is not null) { - _networkMountService.Cleanup(deleteResolution); + NetworkMountService.Cleanup(deleteResolution); } } } diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs index 566ec8d3..c053f1ee 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.ConfigurationOps.cs @@ -236,7 +236,7 @@ private void CleanupIncompleteBackupsOnStartup() Console.WriteLine($"[BackupCleanup] Removed {removed} incomplete backup(s) under '{resolution.EffectivePath}'."); } - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } } catch (Exception ex) @@ -488,7 +488,7 @@ private int ScanDestinationsForUntrackedBackups(List projects, List 0) @@ -1027,7 +1027,7 @@ preparation.DisabledProjects is not { } disabled || { foreach ((_, DestinationResolution resolution) in destinationResolutions) { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } } diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs index a6e99068..f6f2cf8f 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs @@ -607,7 +607,7 @@ private async Task RefreshMetadataNowAsync() } finally { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } } @@ -723,7 +723,7 @@ private DestinationTestResult TryTestDestination(BackupDestination dest, AppConf }; DestinationResolution cleanupResolution = resolution with { Destination = cleanupDest }; - _networkMountService.Cleanup(cleanupResolution); + NetworkMountService.Cleanup(cleanupResolution); } } } diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs index 69709d52..020266db 100644 --- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs +++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs @@ -3210,7 +3210,7 @@ private async Task TestDestinationAsync(BackupDestinationViewModel? dest) } finally { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } }); DestinationTested?.Invoke(destModel, result.success, result.writable, result.message); diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs index b4653388..cfb2fd81 100644 --- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -109,7 +109,7 @@ public void Renew_ExtendsOwnedLeaseAndDisposeReleasesIt() handle.Dispose(); Assert.Equal(RepositoryLeaseState.Available, service.Inspect(root.Path).State); - Assert.Empty(service.ListEvidence(root.Path)); + Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path)); } [Fact] @@ -137,7 +137,7 @@ public void StaleLeaseRequiresExplicitNonceBoundTakeoverAndPreservesEvidence() Assert.False(oldHandle.Renew()); oldHandle.Dispose(); Assert.True(replacement.IsOwner); - RepositoryLeaseEvidence evidence = Assert.Single(service.ListEvidence(root.Path)); + RepositoryLeaseEvidence evidence = Assert.Single(RepositoryLeaseService.ListEvidence(root.Path)); Assert.Equal("stale-takeover", evidence.Disposition); Assert.Equal(ordinaryAcquire.Inspection.Lease?.Nonce, evidence.Nonce); } @@ -222,7 +222,7 @@ public void ListEvidence_MissingDatabaseReturnsEmpty() using var root = new TempDirectory(); var service = new RepositoryLeaseService(); - Assert.Empty(service.ListEvidence(root.Path)); + Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path)); } [Fact] diff --git a/tests/VaultSync.Core.Tests/ThemeManagerTests.cs b/tests/VaultSync.Core.Tests/ThemeManagerTests.cs index 433e0d3d..3c236851 100644 --- a/tests/VaultSync.Core.Tests/ThemeManagerTests.cs +++ b/tests/VaultSync.Core.Tests/ThemeManagerTests.cs @@ -44,9 +44,9 @@ public void ThemePresets_AreUniqueCompleteAndParseable() Color.Parse(preset.Palette.SurfaceAlt) ]; Assert.All(surfaces, surface => - Assert.True(ContrastRatio(Color.Parse(preset.Palette.TextPrimary), surface) >= 4.5)); + Assert.True(ThemeColor.ContrastRatio(Color.Parse(preset.Palette.TextPrimary), surface) >= 4.5)); Assert.All(surfaces, surface => - Assert.True(ContrastRatio(Color.Parse(preset.Palette.TextSecondary), surface) >= 3.0)); + Assert.True(ThemeColor.ContrastRatio(Color.Parse(preset.Palette.TextSecondary), surface) >= 3.0)); } } @@ -92,27 +92,28 @@ public void ContrastForegroundConverter_SelectsReadableForeground(string backgro Assert.Equal(Color.Parse(expected), brush.Color); } - private static double ContrastRatio(Color first, Color second) + [Theory] + [InlineData(null, "#123456", "#123456")] + [InlineData(" ", "#123456", "#123456")] + [InlineData("00d9ff", "#123456", "#00D9FF")] + [InlineData(" #14213d ", "#123456", "#14213D")] + [InlineData("not-a-color", "#123456", "#123456")] + public void ThemeColor_NormalizeHex_ProducesCanonicalRgb( + string value, + string fallback, + string expected) { - double firstLuminance = RelativeLuminance(first); - double secondLuminance = RelativeLuminance(second); - double lighter = Math.Max(firstLuminance, secondLuminance); - double darker = Math.Min(firstLuminance, secondLuminance); - return (lighter + 0.05) / (darker + 0.05); + Assert.Equal(expected, ThemeColor.NormalizeHex(value, fallback)); } - private static double RelativeLuminance(Color color) + [Fact] + public void ThemeColor_ContrastRatio_IsSymmetricAndUsesWcagExtremes() { - static double Linearize(byte channel) - { - double value = channel / 255d; - return value <= 0.04045 - ? value / 12.92 - : Math.Pow((value + 0.055) / 1.055, 2.4); - } + double lightToDark = ThemeColor.ContrastRatio(Colors.White, Colors.Black); + double darkToLight = ThemeColor.ContrastRatio(Colors.Black, Colors.White); - return (0.2126 * Linearize(color.R)) - + (0.7152 * Linearize(color.G)) - + (0.0722 * Linearize(color.B)); + Assert.Equal(21d, lightToDark, precision: 10); + Assert.Equal(lightToDark, darkToLight, precision: 10); + Assert.Equal(1d, ThemeColor.ContrastRatio(Colors.Black, Colors.Black), precision: 10); } } From 2e8cd7c83ebf972e9c8d34ab78da628c7e9e4e1b Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:47:04 +0200 Subject: [PATCH 20/54] Decompose metadata import workflows --- .../Services/MetadataSyncService.cs | 740 ++++++++++-------- .../Services/NetworkMountService.cs | 12 +- 2 files changed, 439 insertions(+), 313 deletions(-) diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index e1c0fcf9..6cb20630 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -52,6 +52,45 @@ private sealed record BackupExportWriteContext( string MachineId, bool ForceBackfill); + private sealed record LegacyPreviewContext( + string RootPath, + bool AllowCreateProjects, + LegacyPreviewIndexes Indexes, + LegacyPreviewSeen Seen); + + private sealed record LegacyPreviewIndexes( + IReadOnlySet ProjectsByName, + IReadOnlyDictionary SnapshotExternalMap, + IReadOnlyDictionary BackupExternalMap, + IReadOnlySet ExistingBackupPaths); + + private sealed record LegacyPreviewSeen( + ISet Projects, + ISet Snapshots, + ISet Backups); + + private sealed record PreviewProjectCounts(int Add, int Link); + + private sealed record PreviewTombstoneAnalysis( + HashSet BackupIds, + HashSet SnapshotIds, + int DeleteBackups); + + private sealed record PreviewBackupAnalysis(HashSet LiveSnapshotIds, int Add, int Delete); + + private sealed class LegacyImportState + { + public required Dictionary ProjectsByName { get; init; } + public required IReadOnlyDictionary SnapshotExternalMap { get; init; } + public required IReadOnlyDictionary BackupExternalMap { get; init; } + public required Dictionary ExistingBackupByPath { get; init; } + public HashSet AffectedProjectIds { get; } = []; + public int ImportedProjects { get; set; } + public int ImportedSnapshots { get; set; } + public int ImportedBackups { get; set; } + public int RepairedBackups { get; set; } + } + private readonly SqliteRepository _repo; private readonly IAppConfigStore _configStore; private readonly IInstallationIdentityProvider? _installationIdentityProvider; @@ -576,13 +615,11 @@ private MetadataSyncResult ImportFromStoreInternal( if (string.IsNullOrWhiteSpace(tombstone.EntityId)) continue; - if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase) && + backupExternalMap.TryGetValue(tombstone.EntityId, out int existingId)) { - if (backupExternalMap.TryGetValue(tombstone.EntityId, out int existingId)) - { - _repo.DeleteBackupById(existingId); - appliedTombstones++; - } + _repo.DeleteBackupById(existingId); + appliedTombstones++; } } @@ -619,16 +656,13 @@ private MetadataSyncResult ImportFromStoreInternal( if (project == null) continue; - (int Snapshots, int Files) = _repo.DeleteSnapshotsById(project.Name, [snapshot.Id]); - removedSnapshots += Snapshots; + (int snapshots, _) = _repo.DeleteSnapshotsById(project.Name, [snapshot.Id]); + removedSnapshots += snapshots; } - if (removedSnapshots > 0) + if (removedSnapshots > 0 && opts.ExportMissingTombstonesOnImport) { - if (opts.ExportMissingTombstonesOnImport) - { - TryExportMissingSnapshotTombstones(rootPath, missingSnapshotExternalIds); - } + TryExportMissingSnapshotTombstones(rootPath, missingSnapshotExternalIds); } } @@ -949,73 +983,78 @@ private static bool HasLocalChangesNewerThan(string rootPath, DateTime importedL { try { - var stack = new Stack(); - stack.Push(rootPath); + var stack = new Stack([rootPath]); while (stack.Count > 0) { string current = stack.Pop(); + foreach (string directory in GetTraversableDirectories(current)) + stack.Push(directory); - IEnumerable dirs; - try - { - dirs = Directory.EnumerateDirectories(current); - } - catch - { - continue; - } + if (ContainsFileNewerThan(current, importedLatestUtc)) + return true; + } + } + catch + { + return false; + } - foreach (string dir in dirs) - { - string name = Path.GetFileName(dir); - if (string.Equals(name, VaultSyncDirectoryName, StringComparison.OrdinalIgnoreCase)) - continue; + return false; + } - try - { - var di = new DirectoryInfo(dir); - if (di.Attributes.HasFlag(FileAttributes.ReparsePoint)) - continue; - } - catch - { - continue; - } + private static IEnumerable GetTraversableDirectories(string path) + { + IEnumerable directories; + try + { + directories = Directory.EnumerateDirectories(path); + } + catch + { + return []; + } - stack.Push(dir); - } + return directories.Where(IsTraversableDirectory).ToList(); + } - IEnumerable files; - try - { - files = Directory.EnumerateFiles(current); - } - catch - { - continue; - } + private static bool IsTraversableDirectory(string path) + { + if (string.Equals(Path.GetFileName(path), VaultSyncDirectoryName, StringComparison.OrdinalIgnoreCase)) + return false; - foreach (string file in files) - { - try - { - if (File.GetLastWriteTimeUtc(file) > importedLatestUtc) - return true; - } - catch - { - continue; - } - } - } + try + { + return !new DirectoryInfo(path).Attributes.HasFlag(FileAttributes.ReparsePoint); } catch { return false; } + } - return false; + private static bool ContainsFileNewerThan(string path, DateTime timestampUtc) + { + try + { + return Directory.EnumerateFiles(path).Any(file => IsFileNewerThan(file, timestampUtc)); + } + catch + { + return false; + } + } + + private static bool IsFileNewerThan(string path, DateTime timestampUtc) + { + try + { + return File.GetLastWriteTimeUtc(path) > timestampUtc; + } + catch + { + return false; + } } private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, MetadataStore store, MetadataSyncOptions opts) @@ -1048,12 +1087,6 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta return cached.Preview; } - int addProjects = 0; - int linkProjects = 0; - int addSnapshots = 0; - int addBackups = 0; - int deleteBackups = 0; - var projectMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var localProjects = _repo.GetAllProjects().ToList(); IReadOnlyDictionary projectExternalMap = _repo.GetProjectExternalIdMap(); @@ -1062,17 +1095,17 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta projectMap[pair.Key] = pair.Value; } - IEnumerable metaProjects; - IEnumerable metaSnapshots; - IEnumerable metaBackups; - IEnumerable metaTombstones; + IReadOnlyList metaProjects; + IReadOnlyList metaSnapshots; + IReadOnlyList metaBackups; + IReadOnlyList metaTombstones; try { - metaProjects = store.ListProjects(); - metaSnapshots = store.ListSnapshots(); - metaBackups = store.ListBackups(); - metaTombstones = store.ListTombstones(); + metaProjects = [.. store.ListProjects()]; + metaSnapshots = [.. store.ListSnapshots()]; + metaBackups = [.. store.ListBackups()]; + metaTombstones = [.. store.ListTombstones()]; } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { @@ -1080,34 +1113,11 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta return MetadataSyncPreview.Failure(MetadataSyncStatus.InvalidStore, rootPath, store.DatabasePath, ex.Message); } - foreach (MetaProject metaProject in metaProjects) - { - if (string.IsNullOrWhiteSpace(metaProject.ExternalId)) - continue; - - if (projectMap.ContainsKey(metaProject.ExternalId)) - continue; - - Project? existingByName = localProjects.FirstOrDefault(p => - string.Equals(p.Name, metaProject.Name, StringComparison.OrdinalIgnoreCase)); - - if (existingByName != null) - { - if (string.IsNullOrWhiteSpace(existingByName.ExternalId)) - { - linkProjects++; - } - - projectMap[metaProject.ExternalId] = existingByName.Id; - continue; - } - - if (!opts.AllowCreateProjects) - continue; - - addProjects++; - projectMap[metaProject.ExternalId] = -1; - } + PreviewProjectCounts projectCounts = CountPreviewProjects( + metaProjects, + projectMap, + localProjects, + opts.AllowCreateProjects); IReadOnlyDictionary snapshotExternalMap = _repo.GetSnapshotExternalIdMap(); IReadOnlyDictionary backupExternalMap = _repo.GetBackupExternalIdMap(); @@ -1117,103 +1127,154 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta .Select(backup => NormalizeStablePath(NormalizeBackupPathRel(backup.Path))) .Where(path => !string.IsNullOrWhiteSpace(path)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var tombstonedBackupIds = new HashSet(StringComparer.OrdinalIgnoreCase); - var tombstonedSnapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); - var liveSnapshotExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); + PreviewTombstoneAnalysis tombstones = AnalyzePreviewTombstones(metaTombstones, backupExternalMap); + PreviewBackupAnalysis backups = AnalyzePreviewBackups( + metaBackups, + rootPath, + projectMap, + backupExternalMap, + tombstones.BackupIds); + int addSnapshots = CountPreviewSnapshots( + metaSnapshots, + projectMap, + snapshotExternalMap, + tombstones.SnapshotIds, + backups.LiveSnapshotIds); - foreach (MetaTombstone tombstone in metaTombstones) + MetadataSyncPreview filesystemPreview = PreviewBackupFoldersFromDestination(rootPath, opts, store.DatabasePath); + int addProjects = projectCounts.Add + filesystemPreview.NewProjects; + addSnapshots += filesystemPreview.NewSnapshots; + int addBackups = backups.Add + filesystemPreview.NewBackups; + + var preview = new MetadataSyncPreview( + MetadataSyncStatus.Success, + rootPath, + store.DatabasePath, + addProjects, + projectCounts.Link, + addSnapshots, + addBackups, + tombstones.DeleteBackups + backups.Delete, + string.Empty); + + if (metaInfo != null) { - if (string.IsNullOrWhiteSpace(tombstone.EntityId)) + _previewCache[rootPath] = (metaInfo.LastWriteUtc, preview); + } + + return preview; + } + + private static PreviewProjectCounts CountPreviewProjects( + IEnumerable projects, + IDictionary projectMap, + IReadOnlyCollection localProjects, + bool allowCreateProjects) + { + int add = 0; + int link = 0; + foreach (MetaProject project in projects) + { + if (string.IsNullOrWhiteSpace(project.ExternalId) || projectMap.ContainsKey(project.ExternalId)) continue; - if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) + Project? local = localProjects.FirstOrDefault(candidate => + string.Equals(candidate.Name, project.Name, StringComparison.OrdinalIgnoreCase)); + if (local is not null) { - tombstonedBackupIds.Add(tombstone.EntityId); - if (backupExternalMap.ContainsKey(tombstone.EntityId)) - deleteBackups++; + if (string.IsNullOrWhiteSpace(local.ExternalId)) + link++; + projectMap[project.ExternalId] = local.Id; } - else if (string.Equals(tombstone.EntityType, "snapshot", StringComparison.OrdinalIgnoreCase)) + else if (allowCreateProjects) { - tombstonedSnapshotIds.Add(tombstone.EntityId); + add++; + projectMap[project.ExternalId] = -1; } } - foreach (MetaBackup metaBackup in metaBackups) - { - if (string.IsNullOrWhiteSpace(metaBackup.ExternalId)) - continue; + return new PreviewProjectCounts(add, link); + } - if (!string.IsNullOrWhiteSpace(metaBackup.SnapshotExternalId) && - !tombstonedBackupIds.Contains(metaBackup.ExternalId)) + private static PreviewTombstoneAnalysis AnalyzePreviewTombstones( + IEnumerable tombstones, + IReadOnlyDictionary backupExternalMap) + { + var backupIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var snapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); + int deletes = 0; + foreach (MetaTombstone tombstone in tombstones.Where(tombstone => !string.IsNullOrWhiteSpace(tombstone.EntityId))) + { + if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) { - liveSnapshotExternalIds.Add(metaBackup.SnapshotExternalId); + backupIds.Add(tombstone.EntityId); + deletes += backupExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; } - - if (tombstonedBackupIds.Contains(metaBackup.ExternalId)) - continue; - - if (!TryResolveBackupPath(rootPath, metaBackup.PathRel, out _)) + else if (string.Equals(tombstone.EntityType, "snapshot", StringComparison.OrdinalIgnoreCase)) { - tombstonedBackupIds.Add(metaBackup.ExternalId); - if (backupExternalMap.ContainsKey(metaBackup.ExternalId)) - deleteBackups++; - continue; + snapshotIds.Add(tombstone.EntityId); } - - if (!projectMap.ContainsKey(metaBackup.ProjectExternalId)) - continue; - - if (backupExternalMap.ContainsKey(metaBackup.ExternalId)) - continue; - - addBackups++; } - foreach (MetaSnapshot metaSnapshot in metaSnapshots) + return new PreviewTombstoneAnalysis(backupIds, snapshotIds, deletes); + } + + private static PreviewBackupAnalysis AnalyzePreviewBackups( + IEnumerable backups, + string rootPath, + IReadOnlyDictionary projectMap, + IReadOnlyDictionary backupExternalMap, + ISet tombstonedBackupIds) + { + var liveSnapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); + int add = 0; + int delete = 0; + foreach (MetaBackup backup in backups) { - if (string.IsNullOrWhiteSpace(metaSnapshot.ExternalId)) + if (string.IsNullOrWhiteSpace(backup.ExternalId)) continue; - if (!liveSnapshotExternalIds.Contains(metaSnapshot.ExternalId)) + if (!string.IsNullOrWhiteSpace(backup.SnapshotExternalId) && !tombstonedBackupIds.Contains(backup.ExternalId)) + liveSnapshotIds.Add(backup.SnapshotExternalId); + if (tombstonedBackupIds.Contains(backup.ExternalId)) + continue; + if (!TryResolveBackupPath(rootPath, backup.PathRel, out _)) { - tombstonedSnapshotIds.Add(metaSnapshot.ExternalId); + tombstonedBackupIds.Add(backup.ExternalId); + delete += backupExternalMap.ContainsKey(backup.ExternalId) ? 1 : 0; continue; } - - if (tombstonedSnapshotIds.Contains(metaSnapshot.ExternalId)) - continue; - - if (!projectMap.ContainsKey(metaSnapshot.ProjectExternalId)) - continue; - - if (snapshotExternalMap.ContainsKey(metaSnapshot.ExternalId)) - continue; - - addSnapshots++; + if (projectMap.ContainsKey(backup.ProjectExternalId) && !backupExternalMap.ContainsKey(backup.ExternalId)) + add++; } - MetadataSyncPreview filesystemPreview = PreviewBackupFoldersFromDestination(rootPath, opts, store.DatabasePath); - addProjects += filesystemPreview.NewProjects; - addSnapshots += filesystemPreview.NewSnapshots; - addBackups += filesystemPreview.NewBackups; - - var preview = new MetadataSyncPreview( - MetadataSyncStatus.Success, - rootPath, - store.DatabasePath, - addProjects, - linkProjects, - addSnapshots, - addBackups, - deleteBackups, - string.Empty); + return new PreviewBackupAnalysis(liveSnapshotIds, add, delete); + } - if (metaInfo != null) + private static int CountPreviewSnapshots( + IEnumerable snapshots, + IReadOnlyDictionary projectMap, + IReadOnlyDictionary snapshotExternalMap, + ISet tombstonedSnapshotIds, + IReadOnlySet liveSnapshotIds) + { + int add = 0; + foreach (MetaSnapshot snapshot in snapshots.Where(snapshot => !string.IsNullOrWhiteSpace(snapshot.ExternalId))) { - _previewCache[rootPath] = (metaInfo.LastWriteUtc, preview); + if (!liveSnapshotIds.Contains(snapshot.ExternalId)) + { + tombstonedSnapshotIds.Add(snapshot.ExternalId); + continue; + } + if (!tombstonedSnapshotIds.Contains(snapshot.ExternalId) && + projectMap.ContainsKey(snapshot.ProjectExternalId) && + !snapshotExternalMap.ContainsKey(snapshot.ExternalId)) + { + add++; + } } - return preview; + return add; } private MetadataSyncResult ImportBackupFoldersFromDestination(string rootPath, MetadataSyncOptions opts, AppConfig config) @@ -1229,11 +1290,6 @@ private MetadataSyncResult ImportBackupFoldersFromDestination(string rootPath, M return new MetadataSyncResult(MetadataSyncStatus.Success, 0, 0, 0, 0, string.Empty); } - int importedProjects = 0; - int importedSnapshots = 0; - int importedBackups = 0; - int repairedBackups = 0; - var affectedProjectIds = new HashSet(); var projectsByName = _repo .GetAllProjects() .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); @@ -1251,130 +1307,196 @@ private MetadataSyncResult ImportBackupFoldersFromDestination(string rootPath, M .GroupBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().Backup, StringComparer.OrdinalIgnoreCase); + var state = new LegacyImportState + { + ProjectsByName = projectsByName, + SnapshotExternalMap = snapshotExternalMap, + BackupExternalMap = backupExternalMap, + ExistingBackupByPath = existingBackupByPath + }; foreach (IGrouping projectGroup in discovered.GroupBy(folder => folder.ProjectName, StringComparer.OrdinalIgnoreCase)) + ImportLegacyProjectGroup(projectGroup, rootPath, opts, config, state); + + return new MetadataSyncResult( + MetadataSyncStatus.Success, + state.ImportedProjects, + state.ImportedSnapshots, + state.ImportedBackups, + 0, + string.Empty) { - List importableFolders = [.. projectGroup - .Where(folder => !existingBackupByPath.ContainsKey(NormalizeStablePath(folder.RelativePath))) - .OrderBy(folder => folder.CreatedUtc)]; - List repairableFolders = [.. projectGroup - .Where(folder => - existingBackupByPath.TryGetValue(NormalizeStablePath(folder.RelativePath), out Backup? backup) && - backup.IsImported && - backup.TotalBytes <= 0) - .OrderBy(folder => folder.CreatedUtc)]; - if (importableFolders.Count == 0 && repairableFolders.Count == 0) - continue; + AffectedProjectIds = [.. state.AffectedProjectIds], + RepairedBackups = state.RepairedBackups + }; + } - string projectName = projectGroup.Key; - string projectExternalId = BuildStableExternalId("legacy-project", rootPath, projectName); + private void ImportLegacyProjectGroup( + IGrouping projectGroup, + string rootPath, + MetadataSyncOptions options, + AppConfig config, + LegacyImportState state) + { + List importableFolders = [.. projectGroup + .Where(folder => !state.ExistingBackupByPath.ContainsKey(NormalizeStablePath(folder.RelativePath))) + .OrderBy(folder => folder.CreatedUtc)]; + List repairableFolders = [.. projectGroup + .Where(folder => IsRepairableLegacyBackup(folder, state.ExistingBackupByPath)) + .OrderBy(folder => folder.CreatedUtc)]; + if (importableFolders.Count == 0 && repairableFolders.Count == 0) + return; - if (!projectsByName.TryGetValue(projectName, out Project? project)) - { - if (!opts.AllowCreateProjects) - continue; + Project? project = ResolveLegacyProject(projectGroup, rootPath, options, config, state); + if (project is null) + return; - string projectRoot = ResolveImportedProjectRoot(null, config.ProjectsRoot, projectName, projectExternalId); - int projectId = _repo.AddProject(new Project - { - ExternalId = projectExternalId, - Name = projectName, - RootPath = projectRoot, - Preset = "generic", - CreatedUtc = projectGroup.Min(folder => folder.CreatedUtc), - NeedsRestore = false - }); + RepairLegacyBackups(repairableFolders, rootPath, state); + ImportLegacyBackups(importableFolders, rootPath, project, state); + if (options.MarkNeedsRestoreOnImport && state.AffectedProjectIds.Contains(project.Id)) + _repo.UpdateProjectNeedsRestore(project.Id, true); + } - project = _repo.GetProjectById(projectId); - if (project is null) - continue; + private static bool IsRepairableLegacyBackup( + LegacyBackupFolder folder, + IReadOnlyDictionary existingBackupByPath) => + existingBackupByPath.TryGetValue(NormalizeStablePath(folder.RelativePath), out Backup? backup) && + backup.IsImported && + backup.TotalBytes <= 0; - projectsByName[projectName] = project; - importedProjects++; - } - else if (string.IsNullOrWhiteSpace(project.ExternalId)) - { - _repo.UpdateProjectExternalId(project.Id, projectExternalId); - project = project with { ExternalId = projectExternalId }; - projectsByName[projectName] = project; - } + private Project? ResolveLegacyProject( + IGrouping projectGroup, + string rootPath, + MetadataSyncOptions options, + AppConfig config, + LegacyImportState state) + { + string projectName = projectGroup.Key; + string externalId = BuildStableExternalId("legacy-project", rootPath, projectName); + if (!state.ProjectsByName.TryGetValue(projectName, out Project? project)) + return options.AllowCreateProjects + ? CreateLegacyProject(projectGroup, config, externalId, state) + : null; - foreach (LegacyBackupFolder folder in repairableFolders) - { - string normalizedRelativePath = NormalizeStablePath(folder.RelativePath); - if (!existingBackupByPath.TryGetValue(normalizedRelativePath, out Backup? existingBackup)) - continue; + if (string.IsNullOrWhiteSpace(project.ExternalId)) + { + _repo.UpdateProjectExternalId(project.Id, externalId); + project = project with { ExternalId = externalId }; + state.ProjectsByName[projectName] = project; + } - long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); - if (sizeBytes <= 0) - continue; + return project; + } - _repo.UpdateBackupTotalBytes(existingBackup.Id, sizeBytes); - Snapshot? existingSnapshot = _repo.GetSnapshotById(existingBackup.SnapshotId); - if (existingSnapshot is not null && existingSnapshot.TotalBytes <= 0) - _repo.UpdateSnapshotTotalBytes(existingSnapshot.Id, sizeBytes); + private Project? CreateLegacyProject( + IGrouping projectGroup, + AppConfig config, + string externalId, + LegacyImportState state) + { + string projectRoot = ResolveImportedProjectRoot(null, config.ProjectsRoot, projectGroup.Key, externalId); + int projectId = _repo.AddProject(new Project + { + ExternalId = externalId, + Name = projectGroup.Key, + RootPath = projectRoot, + Preset = "generic", + CreatedUtc = projectGroup.Min(folder => folder.CreatedUtc), + NeedsRestore = false + }); + Project? project = _repo.GetProjectById(projectId); + if (project is null) + return null; - repairedBackups++; - affectedProjectIds.Add(existingBackup.ProjectId); - } + state.ProjectsByName[projectGroup.Key] = project; + state.ImportedProjects++; + return project; + } - foreach (LegacyBackupFolder folder in importableFolders) - { - string normalizedRelativePath = NormalizeStablePath(folder.RelativePath); - string snapshotExternalId = BuildStableExternalId("legacy-snapshot", rootPath, folder.RelativePath); - string backupExternalId = BuildStableExternalId("legacy-backup", rootPath, folder.RelativePath); - long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); + private void RepairLegacyBackups( + IEnumerable folders, + string rootPath, + LegacyImportState state) + { + foreach (LegacyBackupFolder folder in folders) + { + string path = NormalizeStablePath(folder.RelativePath); + if (!state.ExistingBackupByPath.TryGetValue(path, out Backup? backup)) + continue; - if (!snapshotExternalMap.TryGetValue(snapshotExternalId, out int snapshotId)) - { - snapshotId = _repo.CreateSnapshotFromMetadata( - snapshotExternalId, - project.Id, - folder.CreatedUtc, - fileCount: 0, - totalBytes: sizeBytes); - importedSnapshots++; - } + long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); + if (sizeBytes <= 0) + continue; - if (backupExternalMap.ContainsKey(backupExternalId)) - continue; + _repo.UpdateBackupTotalBytes(backup.Id, sizeBytes); + Snapshot? snapshot = _repo.GetSnapshotById(backup.SnapshotId); + if (snapshot is not null && snapshot.TotalBytes <= 0) + _repo.UpdateSnapshotTotalBytes(snapshot.Id, sizeBytes); - _repo.CreateBackupFromMetadata( - backupExternalId, - project.Id, - snapshotId, - folder.CreatedUtc, - "manual", - sizeBytes, - folder.RelativePath, - rootPath, - string.Empty, - isProtected: false, - isImported: true, - backupMode: BackupModes.Full); + state.RepairedBackups++; + state.AffectedProjectIds.Add(backup.ProjectId); + } + } - importedBackups++; - affectedProjectIds.Add(project.Id); - existingBackupByPath[normalizedRelativePath] = _repo.GetBackupByExternalId(backupExternalId) - ?? new Backup { Id = 0, ProjectId = project.Id, SnapshotId = snapshotId, Path = folder.RelativePath, TotalBytes = sizeBytes, IsImported = true }; - } + private void ImportLegacyBackups( + IEnumerable folders, + string rootPath, + Project project, + LegacyImportState state) + { + foreach (LegacyBackupFolder folder in folders) + ImportLegacyBackup(folder, rootPath, project, state); + } - if (opts.MarkNeedsRestoreOnImport && affectedProjectIds.Contains(project.Id)) - { - _repo.UpdateProjectNeedsRestore(project.Id, true); - } - } + private void ImportLegacyBackup( + LegacyBackupFolder folder, + string rootPath, + Project project, + LegacyImportState state) + { + string normalizedPath = NormalizeStablePath(folder.RelativePath); + string snapshotExternalId = BuildStableExternalId("legacy-snapshot", rootPath, folder.RelativePath); + string backupExternalId = BuildStableExternalId("legacy-backup", rootPath, folder.RelativePath); + long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); + int snapshotId = ResolveLegacySnapshot(snapshotExternalId, project.Id, folder, sizeBytes, state); + if (state.BackupExternalMap.ContainsKey(backupExternalId)) + return; - return new MetadataSyncResult( - MetadataSyncStatus.Success, - importedProjects, - importedSnapshots, - importedBackups, - 0, - string.Empty) - { - AffectedProjectIds = [.. affectedProjectIds], - RepairedBackups = repairedBackups - }; + _repo.CreateBackupFromMetadata( + backupExternalId, + project.Id, + snapshotId, + folder.CreatedUtc, + "manual", + sizeBytes, + folder.RelativePath, + rootPath, + string.Empty, + isProtected: false, + isImported: true, + backupMode: BackupModes.Full); + state.ImportedBackups++; + state.AffectedProjectIds.Add(project.Id); + state.ExistingBackupByPath[normalizedPath] = _repo.GetBackupByExternalId(backupExternalId) + ?? new Backup { Id = 0, ProjectId = project.Id, SnapshotId = snapshotId, Path = folder.RelativePath, TotalBytes = sizeBytes, IsImported = true }; + } + + private int ResolveLegacySnapshot( + string externalId, + int projectId, + LegacyBackupFolder folder, + long sizeBytes, + LegacyImportState state) + { + if (state.SnapshotExternalMap.TryGetValue(externalId, out int snapshotId)) + return snapshotId; + + state.ImportedSnapshots++; + return _repo.CreateSnapshotFromMetadata( + externalId, + projectId, + folder.CreatedUtc, + fileCount: 0, + totalBytes: sizeBytes); } private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath, MetadataSyncOptions opts, string databasePath) @@ -1409,29 +1531,18 @@ private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath, var previewedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); var previewedSnapshots = new HashSet(StringComparer.OrdinalIgnoreCase); var previewedBackups = new HashSet(StringComparer.OrdinalIgnoreCase); + var previewContext = new LegacyPreviewContext( + rootPath, + opts.AllowCreateProjects, + new LegacyPreviewIndexes(projectsByName, snapshotExternalMap, backupExternalMap, existingBackupPaths), + new LegacyPreviewSeen(previewedProjects, previewedSnapshots, previewedBackups)); foreach (LegacyBackupFolder folder in discovered) { - string normalizedRelativePath = NormalizeStablePath(folder.RelativePath); - if (existingBackupPaths.Contains(normalizedRelativePath)) - continue; - - bool projectExists = projectsByName.Contains(folder.ProjectName); - if (!projectExists && !opts.AllowCreateProjects) - continue; - - if (!projectExists && previewedProjects.Add(folder.ProjectName)) - { - addProjects++; - } - - string snapshotExternalId = BuildStableExternalId("legacy-snapshot", rootPath, folder.RelativePath); - if (!snapshotExternalMap.ContainsKey(snapshotExternalId) && previewedSnapshots.Add(snapshotExternalId)) - addSnapshots++; - - string backupExternalId = BuildStableExternalId("legacy-backup", rootPath, folder.RelativePath); - if (!backupExternalMap.ContainsKey(backupExternalId) && previewedBackups.Add(backupExternalId)) - addBackups++; + (int projects, int snapshots, int backups) = CountLegacyFolderPreview(folder, previewContext); + addProjects += projects; + addSnapshots += snapshots; + addBackups += backups; } return new MetadataSyncPreview( @@ -1446,6 +1557,25 @@ private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath, string.Empty); } + private static (int Projects, int Snapshots, int Backups) CountLegacyFolderPreview( + LegacyBackupFolder folder, + LegacyPreviewContext context) + { + if (context.Indexes.ExistingBackupPaths.Contains(NormalizeStablePath(folder.RelativePath))) + return default; + + bool projectExists = context.Indexes.ProjectsByName.Contains(folder.ProjectName); + if (!projectExists && !context.AllowCreateProjects) + return default; + + int projects = !projectExists && context.Seen.Projects.Add(folder.ProjectName) ? 1 : 0; + string snapshotExternalId = BuildStableExternalId("legacy-snapshot", context.RootPath, folder.RelativePath); + int snapshots = !context.Indexes.SnapshotExternalMap.ContainsKey(snapshotExternalId) && context.Seen.Snapshots.Add(snapshotExternalId) ? 1 : 0; + string backupExternalId = BuildStableExternalId("legacy-backup", context.RootPath, folder.RelativePath); + int backups = !context.Indexes.BackupExternalMap.ContainsKey(backupExternalId) && context.Seen.Backups.Add(backupExternalId) ? 1 : 0; + return (projects, snapshots, backups); + } + private static IReadOnlyList DiscoverLegacyBackupFolders(string rootPath) { var result = new List(); diff --git a/src/VaultSync.Core/Services/NetworkMountService.cs b/src/VaultSync.Core/Services/NetworkMountService.cs index 04548a8e..8f169fb9 100644 --- a/src/VaultSync.Core/Services/NetworkMountService.cs +++ b/src/VaultSync.Core/Services/NetworkMountService.cs @@ -546,19 +546,15 @@ private static bool IsSmbfsMountPoint(string mountPoint, out string? mountLine) try { - foreach (MacMountEntry mount in mounts.Where( - mount => string.Equals(mount.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase))) - { - mountLine = mount.RawLine; - return true; - } + MacMountEntry? mount = mounts.FirstOrDefault( + candidate => string.Equals(candidate.MountPoint, mountPoint, StringComparison.OrdinalIgnoreCase)); + mountLine = mount?.RawLine; + return mount is not null; } catch { return false; } - - return false; } private static string? FindExistingMountPoint(string shareName, string mountRoot) From 1e4e135a385e1106d5861626d911d3316a0ee8b3 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:53:31 +0200 Subject: [PATCH 21/54] Standardize metadata import helpers --- .../Services/MetadataSyncService.cs | 137 ++++++++---------- 1 file changed, 57 insertions(+), 80 deletions(-) diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 6cb20630..9df85532 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -78,6 +78,13 @@ private sealed record PreviewTombstoneAnalysis( private sealed record PreviewBackupAnalysis(HashSet LiveSnapshotIds, int Add, int Delete); + private sealed record ProjectMetadataConflictContext( + Project Current, + MetaProject Imported, + string? SourceMachineId, + ProjectMetadataConflictValues Local, + ProjectMetadataConflictValues Incoming); + private sealed class LegacyImportState { public required Dictionary ProjectsByName { get; init; } @@ -1121,12 +1128,6 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta IReadOnlyDictionary snapshotExternalMap = _repo.GetSnapshotExternalIdMap(); IReadOnlyDictionary backupExternalMap = _repo.GetBackupExternalIdMap(); - var existingBackupPaths = _repo - .GetAllProjects() - .SelectMany(project => _repo.GetBackupsForProject(project.Id)) - .Select(backup => NormalizeStablePath(NormalizeBackupPathRel(backup.Path))) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); PreviewTombstoneAnalysis tombstones = AnalyzePreviewTombstones(metaTombstones, backupExternalMap); PreviewBackupAnalysis backups = AnalyzePreviewBackups( metaBackups, @@ -1417,13 +1418,13 @@ private void RepairLegacyBackups( string rootPath, LegacyImportState state) { - foreach (LegacyBackupFolder folder in folders) + foreach (string relativePath in folders.Select(folder => folder.RelativePath)) { - string path = NormalizeStablePath(folder.RelativePath); + string path = NormalizeStablePath(relativePath); if (!state.ExistingBackupByPath.TryGetValue(path, out Backup? backup)) continue; - long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); + long sizeBytes = GetLegacyBackupFolderSize(rootPath, relativePath); if (sizeBytes <= 0) continue; @@ -1585,8 +1586,9 @@ private static IReadOnlyList DiscoverLegacyBackupFolders(str { projectDirs = Directory.EnumerateDirectories(rootPath); } - catch + catch (Exception ex) { + RuntimeLog.WriteVerbose($"[MetadataSync] Existing project root could not be inspected: {ex.Message}"); return result; } @@ -2008,8 +2010,9 @@ private static bool IsVaultSyncTransientTempPath(string path) if (relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)) return false; + char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; var firstSegment = relative - .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Split(separators, StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(segment => !string.IsNullOrWhiteSpace(segment)); return firstSegment is not null && @@ -3314,23 +3317,7 @@ private readonly record struct ParsedProjectSettings( private ParsedProjectSettings ParseProjectSettings(string? settingsJson) { if (string.IsNullOrWhiteSpace(settingsJson)) - { - return new ParsedProjectSettings( - ProjectEncryptionPolicy.Inherit, - null, - string.Empty, - ProjectRestoreMode.Direct, - ProjectVerificationPolicy.Always, - true, - string.Empty, - HasEncryptionPolicy: false, - HasEncryptionKeyRef: false, - HasPreferredDestinationId: false, - HasRestoreMode: false, - HasVerificationPolicy: false, - HasAutoBackupEnabled: false, - HasTags: false); - } + return EmptyParsedProjectSettings(); try { @@ -3419,24 +3406,27 @@ private ParsedProjectSettings ParseProjectSettings(string? settingsJson) } catch { - return new ParsedProjectSettings( - ProjectEncryptionPolicy.Inherit, - null, - string.Empty, - ProjectRestoreMode.Direct, - ProjectVerificationPolicy.Always, - true, - string.Empty, - HasEncryptionPolicy: false, - HasEncryptionKeyRef: false, - HasPreferredDestinationId: false, - HasRestoreMode: false, - HasVerificationPolicy: false, - HasAutoBackupEnabled: false, - HasTags: false); + return EmptyParsedProjectSettings(); } } + private static ParsedProjectSettings EmptyParsedProjectSettings() => + new( + ProjectEncryptionPolicy.Inherit, + null, + string.Empty, + ProjectRestoreMode.Direct, + ProjectVerificationPolicy.Always, + true, + string.Empty, + HasEncryptionPolicy: false, + HasEncryptionKeyRef: false, + HasPreferredDestinationId: false, + HasRestoreMode: false, + HasVerificationPolicy: false, + HasAutoBackupEnabled: false, + HasTags: false); + private bool ApplyImportedProjectSettings( int projectId, AppConfig config, @@ -3533,17 +3523,24 @@ private bool ApplyImportedProjectSettings( } return UpsertProjectMetadataConflict( - current, - metaProject, - sourceMachineId, - currentPreferredDestinationId, - currentRestoreMode, - currentVerificationPolicy, - currentTags, - nextPreferredDestinationId, - nextRestoreMode, - nextVerificationPolicy, - nextTags, + new ProjectMetadataConflictContext( + current, + metaProject, + sourceMachineId, + new ProjectMetadataConflictValues + { + PreferredDestinationId = currentPreferredDestinationId, + RestoreMode = currentRestoreMode, + VerificationPolicy = currentVerificationPolicy, + Tags = currentTags + }, + new ProjectMetadataConflictValues + { + PreferredDestinationId = nextPreferredDestinationId, + RestoreMode = nextRestoreMode, + VerificationPolicy = nextVerificationPolicy, + Tags = nextTags + }), pendingConflicts); } @@ -3576,42 +3573,22 @@ private static bool RemoveProjectMetadataConflict(int projectId, IList pendingConflicts) { + Project current = context.Current; + MetaProject metaProject = context.Imported; var next = new ProjectMetadataConflictRecord { ProjectId = current.Id, ProjectExternalId = string.IsNullOrWhiteSpace(current.ExternalId) ? metaProject.ExternalId : current.ExternalId, ProjectName = current.Name, - SourceMachineId = string.IsNullOrWhiteSpace(sourceMachineId) ? UnknownAppVersion : sourceMachineId, + SourceMachineId = string.IsNullOrWhiteSpace(context.SourceMachineId) ? UnknownAppVersion : context.SourceMachineId, SourceUpdatedUtc = metaProject.UpdatedUtc == default ? string.Empty : metaProject.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), - Local = new ProjectMetadataConflictValues - { - PreferredDestinationId = currentPreferredDestinationId, - RestoreMode = currentRestoreMode, - VerificationPolicy = currentVerificationPolicy, - Tags = currentTags - }, - Imported = new ProjectMetadataConflictValues - { - PreferredDestinationId = importedPreferredDestinationId, - RestoreMode = importedRestoreMode, - VerificationPolicy = importedVerificationPolicy, - Tags = importedTags - } + Local = context.Local, + Imported = context.Incoming }; ProjectMetadataConflictRecord? existing = pendingConflicts.FirstOrDefault(conflict => From dbe70fb6f127318c4c0fa71caa790a5154fffc2d Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:58:52 +0200 Subject: [PATCH 22/54] Clear metadata analyzer edge cases --- src/VaultSync.CLI/Commands/DestinationCommand.cs | 6 ++++-- src/VaultSync.Core/Services/MetadataSyncService.cs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/VaultSync.CLI/Commands/DestinationCommand.cs b/src/VaultSync.CLI/Commands/DestinationCommand.cs index 2b3667c6..148e7a61 100644 --- a/src/VaultSync.CLI/Commands/DestinationCommand.cs +++ b/src/VaultSync.CLI/Commands/DestinationCommand.cs @@ -79,8 +79,9 @@ private static DestinationInfo TestDestination( NetworkCredentialProfile? profile = ResolveCredential(config, dest); DestinationResolution resolution = mountService.PrepareDestination(dest, profile); bool reachable = resolution.IsSuccess; + string defaultMessage = reachable ? "Reachable" : "Unreachable"; string message = string.IsNullOrWhiteSpace(resolution.Message) - ? (reachable ? "Reachable" : "Unreachable") + ? defaultMessage : resolution.Message; NetworkMountService.Cleanup(resolution); return new DestinationInfo(alias, path, status, reachable, message); @@ -114,8 +115,9 @@ private static void WriteTable(IEnumerable results, bool test) foreach (DestinationInfo row in results) { + string testedDetail = row.Reachable ? "Reachable" : row.Message; string detail = test - ? (row.Reachable ? "Reachable" : row.Message) + ? testedDetail : row.Message; table.AddRow(row.Alias, row.Path, row.Status, detail); diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 9df85532..8562c03e 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -1588,7 +1588,7 @@ private static IReadOnlyList DiscoverLegacyBackupFolders(str } catch (Exception ex) { - RuntimeLog.WriteVerbose($"[MetadataSync] Existing project root could not be inspected: {ex.Message}"); + RuntimeLog.WriteVerbose($"[MetadataSync] Legacy backup root could not be enumerated: {ex.Message}"); return result; } @@ -1905,8 +1905,9 @@ private static bool ShouldRepairImportedProjectRoot(string? existingRoot, string if (Directory.Exists(existingRoot)) return false; } - catch + catch (Exception ex) { + RuntimeLog.WriteVerbose($"[MetadataSync] Existing project root could not be inspected: {ex.Message}"); } return Directory.Exists(importedRoot); From c08ed52d8a6b925a8855b998d2db5194b7fc0a8f Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:15:18 +0200 Subject: [PATCH 23/54] Generate canonical release manifests --- .github/workflows/pr-quality.yml | 2 +- .github/workflows/release-assets.yml | 48 +++ CHANGELOG.md | 2 + DOCUMENTATION.md | 2 + docs/README.md | 2 + docs/RELEASE_1.8.7.md | 9 +- docs/RELEASING.md | 23 ++ docs/schemas/release-manifest-v1.schema.json | 47 +++ scripts/release_manifest.py | 359 +++++++++++++++++++ scripts/release_readiness_gate.ps1 | 44 ++- tests/scripts/test_release_manifest.py | 190 ++++++++++ 11 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 docs/schemas/release-manifest-v1.schema.json create mode 100644 scripts/release_manifest.py create mode 100644 tests/scripts/test_release_manifest.py diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 231714ad..fd56ba11 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -70,7 +70,7 @@ jobs: set_flag workflows_changed "$(has_change '^(\.github/workflows/|\.github/dependabot\.yml$)' && echo true || echo false)" set_flag templates_changed "$(has_change '^(\.github/ISSUE_TEMPLATE/|\.github/PULL_REQUEST_TEMPLATE\.md$)' && echo true || echo false)" set_flag scripts_changed "$(has_change '^(scripts/|tests/scripts/)' && echo true || echo false)" - set_flag release_changed "$(has_change '^(CHANGELOG\.md|ROADMAP\.md|docs/WHATS_NEW\.md|docs/RELEASING\.md|docs/MICROSOFT_STORE|installer/|packaging/|src/VaultSync\.UI/VaultSync\.UI\.csproj|\.github/workflows/release-assets\.yml|scripts/release_readiness_gate\.ps1)' && echo true || echo false)" + set_flag release_changed "$(has_change '^(CHANGELOG\.md|ROADMAP\.md|docs/WHATS_NEW\.md|docs/RELEASING\.md|docs/MICROSOFT_STORE|docs/schemas/|installer/|packaging/|src/VaultSync\.UI/VaultSync\.UI\.csproj|\.github/workflows/release-assets\.yml|scripts/release_(manifest\.py|readiness_gate\.ps1))' && echo true || echo false)" set_flag store_changed "$(has_change '^(packaging/VaultSync\.Store/|docs/MICROSOFT_STORE|src/VaultSync\.UI/VaultSync\.UI\.csproj)' && echo true || echo false)" echo "" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 0e8524b4..b0a3983c 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -637,3 +637,51 @@ jobs: patches/v${{ inputs.target_version }}/vaultsync-patch-linux-x64.zip patches/v${{ inputs.target_version }}/vaultsync-patch-linux-arm64.json patches/v${{ inputs.target_version }}/vaultsync-patch-linux-arm64.zip + + release-manifest: + name: Generate canonical release manifest + needs: [validate, windows, macos, linux] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download direct-release artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: "*-release-assets" + path: release-assets + merge-multiple: true + + - name: Generate and verify canonical manifest + shell: bash + env: + INCLUDE_LINUX_PATCHES: ${{ inputs.include_linux_patches }} + PREVIOUS_VERSION: ${{ inputs.previous_version }} + RELEASE_CHANNEL: ${{ inputs.release_channel }} + TARGET_VERSION: ${{ inputs.target_version }} + run: | + set -euo pipefail + optional_args=() + if [[ "$INCLUDE_LINUX_PATCHES" == "true" ]]; then + optional_args+=( --include-linux-patches ) + fi + python3 scripts/release_manifest.py generate \ + --asset-root release-assets \ + --output release-assets/vaultsync-release-manifest.json \ + --version "$TARGET_VERSION" \ + --channel "$RELEASE_CHANNEL" \ + --commit "${{ github.sha }}" \ + --repository "${{ github.repository }}" \ + --previous "$PREVIOUS_VERSION" \ + "${optional_args[@]}" + python3 scripts/release_manifest.py validate \ + --manifest release-assets/vaultsync-release-manifest.json \ + --asset-root release-assets + + - name: Upload canonical release manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: canonical-release-manifest + if-no-files-found: error + path: release-assets/vaultsync-release-manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ff035e6c..0fcae23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [1.8.7] - Unreleased + ### Added +- [VS-1872] Added the versioned canonical release manifest generator and schema, with exact artifact sizes, SHA-256 digests, official download identities, strict platform-matrix validation, deterministic output, and release-workflow generation from the final Windows, macOS, and Linux artifacts. - [VS-1877] Added a durable, owner-private installation identity for cross-machine coordination without treating mutable host names or telemetry identifiers as writer identity. - [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6bcdb9a6..76e2fd8a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -29,6 +29,8 @@ Core pillars: ### 2.2 Operational docs - `docs/HELP.md`: in-app help target and concise user guidance. - `docs/RELEASING.md`: release packaging/publishing flow. +- `docs/schemas/release-manifest-v1.schema.json`: canonical direct-download + artifact identity, size, SHA-256, and compatibility schema. - `docs/RELEASE_1.8.7.md`: active-release status, contracts, sequencing, and gates. - `docs/REPOSITORY_FORMATS.md`: repository layouts, compatibility boundaries, and emergency read-only recovery guidance. diff --git a/docs/README.md b/docs/README.md index dc08cb52..fcf3a140 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ Use this page as the primary index for all project documentation. - Current release highlights: [What's New](WHATS_NEW.md) - Active 1.8.7 development status: [1.8.7 release contract](RELEASE_1.8.7.md) - Release process: [Releasing](RELEASING.md) +- Canonical direct-download manifest schema: + [release manifest v1](schemas/release-manifest-v1.schema.json) - Updater and patch assets: [Updater](UPDATER.md) - Microsoft Store planning and packaging notes: [Microsoft Store](MICROSOFT_STORE.md) - Microsoft Store submission checklist: [Store submission checklist](MICROSOFT_STORE_SUBMISSION_CHECKLIST.md) diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index fbc5721b..568be64d 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -22,7 +22,7 @@ The release branch accumulates the qualified 1.8.7 work. `Dev` is the integration branch; `Stable` represents shipped releases only. A beta is not assumed and must be approved explicitly if the release needs one. -## Status as of 2026-08-12 +## Status as of 2026-08-13 ### Implemented on the release branch @@ -48,6 +48,10 @@ assumed and must be approved explicitly if the release needs one. suppress optional source writes. Deferred stores flush only into an empty destination; divergent destination metadata is preserved for merge review instead of being overwritten. No UI takeover control is exposed yet. +- The canonical release-manifest v1 schema, deterministic generator, artifact + classifier, exact size/SHA-256 verification, and complete platform-matrix + gate are implemented. Release automation generates the manifest only after + all direct-download platform artifacts have been built and collected. These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -58,7 +62,8 @@ branch package. 1. Surface repository status and an explicit stale-takeover decision in UI. 2. Make metadata imports previewable, versioned, durable, and reversible. -3. Generate the release manifest and expose complete build identity. +3. Connect the canonical manifest to post-publish verification and expose its + release identity through the app and CLI. 4. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB behavior before enabling supported concurrent-machine workflows. 5. Reduce codebase duplication and oversized orchestration through shared, diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 15b9b78d..385afa8a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -94,6 +94,27 @@ Patch builds require one qualified predecessor through `previous_version`. This Do not broaden the allowlist to older releases without a separate qualification mechanism and test evidence for every platform. Older or unlisted installs must fall back to the full installer. +After all platform jobs complete, the workflow downloads the artifacts they +actually produced and generates `vaultsync-release-manifest.json`. Its v1 schema +is [`docs/schemas/release-manifest-v1.schema.json`](schemas/release-manifest-v1.schema.json). +The manifest records the release identity, qualified predecessor, source +commit, and each direct-download asset's platform, architecture, package kind, +exact byte size, SHA-256 digest, and official GitHub download URL. The generator +fails on missing, duplicate, unexpected, empty, or altered assets; the manifest +is deliberately excluded from its own digest set. + +To validate a downloaded manifest and its colocated assets offline: + +```bash +python3 scripts/release_manifest.py validate \ + --manifest release-assets/vaultsync-release-manifest.json \ + --asset-root release-assets +``` + +The post-publish readiness gate downloads the manifest and compares it with +GitHub's live asset metadata. Any missing or unexpected name, byte-size change, +digest change, unsafe URL, or schema mismatch blocks the release. + ## 5) Release Checklist - Run the release gate before publishing: ```powershell @@ -108,6 +129,8 @@ Do not broaden the allowlist to older releases without a separate qualification - relevant wiki/help docs updated - build/test validation captured - release assets uploaded (installer/DMG/Linux archives/patch assets) +- canonical release manifest generated from the final direct-download assets + and validated against the same bytes before upload - every direct-download asset exposes a GitHub SHA-256 digest and the updater rejects missing, mismatched, or non-official integrity metadata - Windows SmartScreen and macOS Gatekeeper instructions remain current diff --git a/docs/schemas/release-manifest-v1.schema.json b/docs/schemas/release-manifest-v1.schema.json new file mode 100644 index 00000000..abe2581e --- /dev/null +++ b/docs/schemas/release-manifest-v1.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fglabs.dev/vaultsync/schemas/release-manifest-v1.schema.json", + "title": "VaultSync release manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "release", "assets"], + "properties": { + "schemaVersion": { "const": 1 }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["version", "channel", "tag", "commit", "repository", "compatiblePredecessors"], + "properties": { + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" }, + "channel": { "enum": ["stable", "beta"] }, + "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "compatiblePredecessors": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" } + } + } + }, + "assets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "platform", "architecture", "packageKind", "sizeBytes", "sha256", "downloadUrl"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "platform": { "enum": ["windows", "macos", "linux"] }, + "architecture": { "enum": ["x64", "arm64"] }, + "packageKind": { "enum": ["installer", "store-upload", "disk-image", "archive", "debian-package", "appimage", "patch-manifest", "patch-archive"] }, + "sizeBytes": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "downloadUrl": { "type": "string", "format": "uri", "pattern": "^https://github\\.com/" } + } + } + } + } +} diff --git a/scripts/release_manifest.py b/scripts/release_manifest.py new file mode 100644 index 00000000..0c0641d2 --- /dev/null +++ b/scripts/release_manifest.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Generate and validate VaultSync's canonical release artifact manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from urllib.parse import quote, urlparse + + +SCHEMA_VERSION = 1 +MANIFEST_NAME = "vaultsync-release-manifest.json" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def classify_asset(name: str) -> tuple[str, str, str]: + lower = name.lower() + patch = re.fullmatch( + r"vaultsync-patch-(windows|macos-apple-silicon|macos-intel|linux-x64|linux-arm64)\.(json|zip)", + lower, + ) + if patch: + target, extension = patch.groups() + platform, architecture = { + "windows": ("windows", "x64"), + "macos-apple-silicon": ("macos", "arm64"), + "macos-intel": ("macos", "x64"), + "linux-x64": ("linux", "x64"), + "linux-arm64": ("linux", "arm64"), + }[target] + kind = "patch-manifest" if extension == "json" else "patch-archive" + return platform, architecture, kind + + if re.fullmatch(r"vaultsync-setup-.+\.exe", lower): + return "windows", "x64", "installer" + if re.fullmatch(r"vaultsync-store-.+-x64\.(msixupload|appxupload)", lower): + return "windows", "x64", "store-upload" + if re.fullmatch(r"vaultsync-.+-macos-apple-silicon\.dmg", lower): + return "macos", "arm64", "disk-image" + if re.fullmatch(r"vaultsync-.+-macos-intel\.dmg", lower): + return "macos", "x64", "disk-image" + + linux = re.fullmatch(r"vaultsync-.+-linux-(x64|arm64)\.(tar\.gz|deb|appimage)", lower) + if linux: + architecture, extension = linux.groups() + kind = {"tar.gz": "archive", "deb": "debian-package", "appimage": "appimage"}[extension] + return "linux", architecture, kind + + raise ValueError(f"Unexpected release asset: {name}") + + +def expected_asset_keys(*, include_linux_patches: bool, include_store_upload: bool) -> set[tuple[str, str, str]]: + expected = { + ("windows", "x64", "installer"), + ("windows", "x64", "patch-manifest"), + ("windows", "x64", "patch-archive"), + ("macos", "arm64", "disk-image"), + ("macos", "arm64", "patch-manifest"), + ("macos", "arm64", "patch-archive"), + ("macos", "x64", "disk-image"), + ("macos", "x64", "patch-manifest"), + ("macos", "x64", "patch-archive"), + ("linux", "x64", "archive"), + ("linux", "x64", "debian-package"), + ("linux", "x64", "appimage"), + ("linux", "arm64", "archive"), + ("linux", "arm64", "debian-package"), + } + if include_linux_patches: + expected.update( + { + ("linux", "x64", "patch-manifest"), + ("linux", "x64", "patch-archive"), + ("linux", "arm64", "patch-manifest"), + ("linux", "arm64", "patch-archive"), + } + ) + if include_store_upload: + expected.add(("windows", "x64", "store-upload")) + return expected + + +def collect_assets(root: Path) -> list[Path]: + root = root.resolve(strict=True) + if not root.is_dir(): + raise ValueError(f"Asset root is not a directory: {root}") + + assets: list[Path] = [] + names: set[str] = set() + for candidate in sorted(root.rglob("*"), key=lambda path: path.name.lower()): + if candidate.is_symlink(): + raise ValueError(f"Release assets cannot be symbolic links: {candidate}") + if not candidate.is_file() or candidate.name == MANIFEST_NAME: + continue + key = candidate.name.casefold() + if key in names: + raise ValueError(f"Duplicate release asset name: {candidate.name}") + names.add(key) + classify_asset(candidate.name) + assets.append(candidate) + return assets + + +def build_manifest( + asset_root: Path, + *, + version: str, + channel: str, + commit: str, + repository: str, + predecessors: list[str], + include_linux_patches: bool = False, + include_store_upload: bool = False, +) -> dict[str, object]: + validate_release_identity(version, channel, commit, repository, predecessors) + tag = f"v{version}" + asset_entries: list[dict[str, object]] = [] + actual_keys: set[tuple[str, str, str]] = set() + for path in collect_assets(asset_root): + platform, architecture, package_kind = classify_asset(path.name) + key = (platform, architecture, package_kind) + if key in actual_keys: + raise ValueError(f"Duplicate release asset role: {platform}/{architecture}/{package_kind}") + actual_keys.add(key) + asset_entries.append( + { + "name": path.name, + "platform": platform, + "architecture": architecture, + "packageKind": package_kind, + "sizeBytes": path.stat().st_size, + "sha256": sha256_file(path), + "downloadUrl": f"https://github.com/{repository}/releases/download/{tag}/{quote(path.name)}", + } + ) + + expected = expected_asset_keys( + include_linux_patches=include_linux_patches, + include_store_upload=include_store_upload, + ) + if actual_keys != expected: + missing = sorted(expected - actual_keys) + unexpected = sorted(actual_keys - expected) + raise ValueError(f"Release asset matrix mismatch; missing={missing}, unexpected={unexpected}") + + manifest: dict[str, object] = { + "schemaVersion": SCHEMA_VERSION, + "release": { + "version": version, + "channel": channel, + "tag": tag, + "commit": commit.lower(), + "repository": repository, + "compatiblePredecessors": predecessors, + }, + "assets": sorted(asset_entries, key=lambda asset: str(asset["name"]).lower()), + } + validate_manifest(manifest, asset_root=asset_root) + return manifest + + +def validate_release_identity( + version: str, + channel: str, + commit: str, + repository: str, + predecessors: list[str], +) -> None: + if not VERSION_PATTERN.fullmatch(version): + raise ValueError(f"Invalid release version: {version}") + if channel not in {"stable", "beta"}: + raise ValueError(f"Invalid release channel: {channel}") + if (channel == "stable") != ("-" not in version): + raise ValueError("Stable versions cannot have a suffix and beta versions must have one") + if not COMMIT_PATTERN.fullmatch(commit): + raise ValueError("Release commit must be a full 40-character Git SHA") + if not REPOSITORY_PATTERN.fullmatch(repository): + raise ValueError(f"Invalid GitHub repository: {repository}") + if not predecessors or len(predecessors) != len(set(predecessors)): + raise ValueError("Compatible predecessors must be a non-empty unique list") + if version in predecessors or any(not VERSION_PATTERN.fullmatch(item) for item in predecessors): + raise ValueError("Compatible predecessors must be valid versions different from the target") + + +def validate_manifest(manifest: object, *, asset_root: Path | None = None) -> None: + if not isinstance(manifest, dict) or set(manifest) != {"schemaVersion", "release", "assets"}: + raise ValueError("Manifest must contain only schemaVersion, release, and assets") + if manifest["schemaVersion"] != SCHEMA_VERSION: + raise ValueError(f"Unsupported release manifest schema: {manifest['schemaVersion']}") + + release = manifest["release"] + if not isinstance(release, dict) or set(release) != { + "version", "channel", "tag", "commit", "repository", "compatiblePredecessors" + }: + raise ValueError("Release identity fields do not match schema v1") + validate_release_identity( + str(release["version"]), + str(release["channel"]), + str(release["commit"]), + str(release["repository"]), + release["compatiblePredecessors"] if isinstance(release["compatiblePredecessors"], list) else [], + ) + if release["tag"] != f"v{release['version']}": + raise ValueError("Release tag must be v followed by the exact version") + + assets = manifest["assets"] + if not isinstance(assets, list) or not assets: + raise ValueError("Manifest must contain at least one release asset") + names: set[str] = set() + for asset in assets: + validate_asset_entry(asset, release, names, asset_root) + + +def validate_published_assets(manifest: object, published_assets: object) -> None: + validate_manifest(manifest) + if not isinstance(manifest, dict) or not isinstance(published_assets, list): + raise ValueError("Published asset comparison requires a manifest and GitHub asset array") + + expected = {asset["name"]: asset for asset in manifest["assets"]} + actual: dict[str, dict[str, object]] = {} + for asset in published_assets: + if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): + raise ValueError("GitHub release asset metadata is invalid") + name = asset["name"] + if name == MANIFEST_NAME: + continue + if name in actual: + raise ValueError(f"GitHub release contains a duplicate asset name: {name}") + actual[name] = asset + + if set(actual) != set(expected): + missing = sorted(set(expected) - set(actual)) + unexpected = sorted(set(actual) - set(expected)) + raise ValueError(f"Published release asset set differs from manifest; missing={missing}, unexpected={unexpected}") + + for name, expected_asset in expected.items(): + actual_asset = actual[name] + digest = str(actual_asset.get("digest") or "") + if digest.startswith("sha256:"): + digest = digest.removeprefix("sha256:") + if actual_asset.get("size") != expected_asset["sizeBytes"]: + raise ValueError(f"Published release asset size differs from manifest: {name}") + if digest != expected_asset["sha256"]: + raise ValueError(f"Published release asset digest differs from manifest: {name}") + if actual_asset.get("url") != expected_asset["downloadUrl"]: + raise ValueError(f"Published release asset URL differs from manifest: {name}") + + +def validate_asset_entry(asset: object, release: dict[str, object], names: set[str], asset_root: Path | None) -> None: + fields = {"name", "platform", "architecture", "packageKind", "sizeBytes", "sha256", "downloadUrl"} + if not isinstance(asset, dict) or set(asset) != fields: + raise ValueError("Release asset fields do not match schema v1") + name = asset["name"] + if not isinstance(name, str) or not name or Path(name).name != name: + raise ValueError(f"Unsafe release asset name: {name}") + if name == MANIFEST_NAME or name.casefold() in names: + raise ValueError(f"Duplicate or self-referencing release asset: {name}") + names.add(name.casefold()) + expected_role = classify_asset(name) + if tuple(asset[field] for field in ("platform", "architecture", "packageKind")) != expected_role: + raise ValueError(f"Release asset classification mismatch: {name}") + if not isinstance(asset["sizeBytes"], int) or asset["sizeBytes"] <= 0: + raise ValueError(f"Release asset size must be positive: {name}") + if not isinstance(asset["sha256"], str) or not SHA256_PATTERN.fullmatch(asset["sha256"]): + raise ValueError(f"Invalid SHA-256 digest: {name}") + + expected_url = f"https://github.com/{release['repository']}/releases/download/{release['tag']}/{quote(name)}" + parsed_url = urlparse(str(asset["downloadUrl"])) + if parsed_url.scheme != "https" or parsed_url.hostname != "github.com" or asset["downloadUrl"] != expected_url: + raise ValueError(f"Unsafe or inconsistent release asset URL: {name}") + + if asset_root is not None: + root = asset_root.resolve(strict=True) + matches = [candidate for candidate in root.rglob(name) if candidate.is_file()] + if len(matches) != 1: + raise ValueError(f"Release asset must resolve exactly once beneath the asset root: {name}") + path = matches[0].resolve(strict=True) + if not path.is_relative_to(root) or path.is_symlink() or not path.is_file(): + raise ValueError(f"Release asset is outside the asset root: {name}") + if path.stat().st_size != asset["sizeBytes"] or sha256_file(path) != asset["sha256"]: + raise ValueError(f"Release asset bytes do not match the manifest: {name}") + + +def write_manifest(path: Path, manifest: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + generate = subparsers.add_parser("generate") + generate.add_argument("--asset-root", type=Path, required=True) + generate.add_argument("--output", type=Path, required=True) + generate.add_argument("--version", required=True) + generate.add_argument("--channel", choices=("stable", "beta"), required=True) + generate.add_argument("--commit", required=True) + generate.add_argument("--repository", default="ATAC-Helicopter/VaultSync") + generate.add_argument("--previous", action="append", required=True) + generate.add_argument("--include-linux-patches", action="store_true") + generate.add_argument("--include-store-upload", action="store_true") + validate = subparsers.add_parser("validate") + validate.add_argument("--manifest", type=Path, required=True) + validate.add_argument("--asset-root", type=Path) + validate_published = subparsers.add_parser("validate-published") + validate_published.add_argument("--manifest", type=Path, required=True) + validate_published.add_argument("--github-assets", type=Path, required=True) + args = parser.parse_args() + + try: + if args.command == "generate": + output = args.output.resolve(strict=False) + asset_root = args.asset_root.resolve(strict=True) + if output.name != MANIFEST_NAME or not output.is_relative_to(asset_root): + raise ValueError(f"Output must be named {MANIFEST_NAME} inside the asset root") + manifest = build_manifest( + asset_root, + version=args.version, + channel=args.channel, + commit=args.commit, + repository=args.repository, + predecessors=args.previous, + include_linux_patches=args.include_linux_patches, + include_store_upload=args.include_store_upload, + ) + write_manifest(output, manifest) + print(f"Wrote {output} with {len(manifest['assets'])} assets.") + elif args.command == "validate": + manifest = json.loads(args.manifest.read_text(encoding="utf-8-sig")) + validate_manifest(manifest, asset_root=args.asset_root) + print(f"Validated {args.manifest}.") + else: + manifest = json.loads(args.manifest.read_text(encoding="utf-8-sig")) + published_assets = json.loads(args.github_assets.read_text(encoding="utf-8-sig")) + validate_published_assets(manifest, published_assets) + print(f"Validated published assets against {args.manifest}.") + return 0 + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Release manifest error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_readiness_gate.ps1 b/scripts/release_readiness_gate.ps1 index fe4bd9bb..79a9310a 100644 --- a/scripts/release_readiness_gate.ps1 +++ b/scripts/release_readiness_gate.ps1 @@ -235,6 +235,12 @@ Add-CheckResult -Results $results -Code "script-release-gate" -Condition (Test-P -FailMessage "Release readiness gate script is missing." ` -Data @{ path = "scripts/release_readiness_gate.ps1" } +Add-CheckResult -Results $results -Code "release-manifest-contract" ` + -Condition ((Test-Path "scripts/release_manifest.py") -and (Test-Path "docs/schemas/release-manifest-v1.schema.json")) ` + -PassMessage "Canonical release manifest generator and schema are present." ` + -FailMessage "Canonical release manifest generator or schema is missing." ` + -Data @{ script = "scripts/release_manifest.py"; schema = "docs/schemas/release-manifest-v1.schema.json" } + Add-CheckResult -Results $results -Code "docs-release-checklist" -Condition ($releasingDoc -match 'release assets uploaded' -and $releasingDoc -match 'release_readiness_gate\.ps1') ` -PassMessage "Release guide includes the release gate and asset-upload checklist." ` -FailMessage "Release guide is missing release gate and/or asset-upload checklist coverage." ` @@ -282,6 +288,7 @@ if ($null -eq $release) { $hasInstaller = [bool]($assetNames | Where-Object { $_ -like "VaultSync-Setup-*.exe" } | Select-Object -First 1) $hasPatchManifest = [bool]($assetNames | Where-Object { $_ -like "vaultsync-patch-*.json" } | Select-Object -First 1) $hasPatchArchive = [bool]($assetNames | Where-Object { $_ -like "vaultsync-patch-*.zip" } | Select-Object -First 1) + $hasCanonicalManifest = $assetNames -contains "vaultsync-release-manifest.json" Add-CheckResult -Results $results -Code "github-release" -Condition $true ` -PassMessage "GitHub release '$releaseTag' found." ` @@ -306,12 +313,47 @@ if ($null -eq $release) { -Data @{ expectedPattern = "vaultsync-patch-*.zip"; assets = $assetNames } ` -WarningOnFail:$warnForPublishArtifacts - if ($warnForPublishArtifacts -and (-not ($hasInstaller -and $hasPatchManifest -and $hasPatchArchive))) { + Add-CheckResult -Results $results -Code "asset-release-manifest" -Condition $hasCanonicalManifest ` + -PassMessage "Canonical release manifest is present on the release." ` + -FailMessage "Canonical release manifest is missing from the release." ` + -Data @{ expected = "vaultsync-release-manifest.json"; assets = $assetNames } ` + -WarningOnFail:$warnForPublishArtifacts + + if ($Phase -eq "PostPublish" -and $hasCanonicalManifest) { + $manifestTempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("vaultsync-release-manifest-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $manifestTempRoot | Out-Null + try { + & gh release download $releaseTag --repo $Repository --pattern "vaultsync-release-manifest.json" --dir $manifestTempRoot --clobber + if ($LASTEXITCODE -ne 0) { + throw "Could not download canonical release manifest." + } + + $githubAssetsPath = Join-Path $manifestTempRoot "github-assets.json" + $release.assets | ConvertTo-Json -Depth 8 | Set-Content -Path $githubAssetsPath -Encoding utf8 + $pythonCommand = if (Get-Command python3 -ErrorAction SilentlyContinue) { "python3" } else { "python" } + & $pythonCommand scripts/release_manifest.py validate-published ` + --manifest (Join-Path $manifestTempRoot "vaultsync-release-manifest.json") ` + --github-assets $githubAssetsPath + $manifestMatchesPublishedAssets = ($LASTEXITCODE -eq 0) + } catch { + $manifestMatchesPublishedAssets = $false + } finally { + Remove-Item -LiteralPath $manifestTempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + Add-CheckResult -Results $results -Code "asset-release-manifest-content" -Condition $manifestMatchesPublishedAssets ` + -PassMessage "Published asset names, sizes, SHA-256 digests, and URLs match the canonical manifest." ` + -FailMessage "Published assets do not exactly match the canonical release manifest." ` + -Data @{ manifest = "vaultsync-release-manifest.json"; release = $releaseTag } + } + + if ($warnForPublishArtifacts -and (-not ($hasInstaller -and $hasPatchManifest -and $hasPatchArchive -and $hasCanonicalManifest))) { $results.Add((New-Result -Code "publish-assets-next-step" -Status "warn" -Message "Release exists but assets are incomplete. Run release asset generation before final verification." -Data @{ missing = @( if (-not $hasInstaller) { "installer" } if (-not $hasPatchManifest) { "patch-manifest" } if (-not $hasPatchArchive) { "patch-archive" } + if (-not $hasCanonicalManifest) { "release-manifest" } ) nextSteps = @( "Trigger the release-assets GitHub Actions workflow for the target version.", diff --git a/tests/scripts/test_release_manifest.py b/tests/scripts/test_release_manifest.py new file mode 100644 index 00000000..08762a1c --- /dev/null +++ b/tests/scripts/test_release_manifest.py @@ -0,0 +1,190 @@ +import copy +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = REPO_ROOT / "scripts" / "release_manifest.py" + +spec = importlib.util.spec_from_file_location("release_manifest", MODULE_PATH) +release_manifest = importlib.util.module_from_spec(spec) +assert spec is not None and spec.loader is not None +spec.loader.exec_module(release_manifest) + + +CORE_ASSETS = [ + "VaultSync-Setup-1.8.7.exe", + "vaultsync-patch-windows.json", + "vaultsync-patch-windows.zip", + "VaultSync-1.8.7-macos-apple-silicon.dmg", + "vaultsync-patch-macos-apple-silicon.json", + "vaultsync-patch-macos-apple-silicon.zip", + "VaultSync-1.8.7-macos-intel.dmg", + "vaultsync-patch-macos-intel.json", + "vaultsync-patch-macos-intel.zip", + "VaultSync-1.8.7-linux-x64.tar.gz", + "VaultSync-1.8.7-linux-x64.deb", + "VaultSync-1.8.7-linux-x64.AppImage", + "VaultSync-1.8.7-linux-arm64.tar.gz", + "VaultSync-1.8.7-linux-arm64.deb", +] + + +class ReleaseManifestTests(unittest.TestCase): + def write_assets(self, root: Path, names: list[str] = CORE_ASSETS) -> None: + for index, name in enumerate(names): + platform_dir = root / f"bundle-{index % 3}" + platform_dir.mkdir(parents=True, exist_ok=True) + (platform_dir / name).write_bytes(f"artifact:{name}".encode()) + + def build(self, root: Path) -> dict[str, object]: + return release_manifest.build_manifest( + root, + version="1.8.7", + channel="stable", + commit="a" * 40, + repository="ATAC-Helicopter/VaultSync", + predecessors=["1.8.6"], + ) + + def test_build_manifest_covers_exact_core_matrix_and_is_deterministic(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + + first = self.build(root) + second = self.build(root) + + self.assertEqual(first, second) + self.assertEqual(1, first["schemaVersion"]) + self.assertEqual("v1.8.7", first["release"]["tag"]) + self.assertEqual(len(CORE_ASSETS), len(first["assets"])) + self.assertEqual( + sorted(CORE_ASSETS, key=str.lower), + [asset["name"] for asset in first["assets"]], + ) + release_manifest.validate_manifest(first, asset_root=root) + + def test_build_manifest_supports_optional_linux_patches_and_store_upload(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + names = CORE_ASSETS + [ + "vaultsync-patch-linux-x64.json", + "vaultsync-patch-linux-x64.zip", + "vaultsync-patch-linux-arm64.json", + "vaultsync-patch-linux-arm64.zip", + "VaultSync-Store-1.8.7-x64.msixupload", + ] + self.write_assets(root, names) + + manifest = release_manifest.build_manifest( + root, + version="1.8.7", + channel="stable", + commit="b" * 40, + repository="ATAC-Helicopter/VaultSync", + predecessors=["1.8.6"], + include_linux_patches=True, + include_store_upload=True, + ) + + self.assertEqual(len(names), len(manifest["assets"])) + + def test_build_manifest_rejects_missing_and_unexpected_assets(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root, CORE_ASSETS[:-1]) + with self.assertRaisesRegex(ValueError, "matrix mismatch"): + self.build(root) + + (root / "notes.txt").write_text("not an asset", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "Unexpected release asset"): + self.build(root) + + def test_build_manifest_rejects_duplicate_names_and_roles(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + duplicate = root / "duplicate" + duplicate.mkdir() + (duplicate / CORE_ASSETS[0]).write_bytes(b"duplicate") + with self.assertRaisesRegex(ValueError, "Duplicate release asset name"): + self.build(root) + + def test_validate_manifest_detects_tampering(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + manifest = self.build(root) + asset_name = manifest["assets"][0]["name"] + next(root.rglob(asset_name)).write_bytes(b"tampered") + + with self.assertRaisesRegex(ValueError, "bytes do not match"): + release_manifest.validate_manifest(manifest, asset_root=root) + + def test_validate_manifest_rejects_unsafe_url_hash_and_schema(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + manifest = self.build(root) + + unsafe = copy.deepcopy(manifest) + unsafe["assets"][0]["downloadUrl"] = "https://evil.example/download.exe" + with self.assertRaisesRegex(ValueError, "Unsafe or inconsistent"): + release_manifest.validate_manifest(unsafe) + + bad_hash = copy.deepcopy(manifest) + bad_hash["assets"][0]["sha256"] = "not-a-hash" + with self.assertRaisesRegex(ValueError, "Invalid SHA-256"): + release_manifest.validate_manifest(bad_hash) + + unsupported = copy.deepcopy(manifest) + unsupported["schemaVersion"] = 2 + with self.assertRaisesRegex(ValueError, "Unsupported"): + release_manifest.validate_manifest(unsupported) + + def test_validate_published_assets_requires_exact_github_metadata(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + manifest = self.build(root) + published = [ + { + "name": asset["name"], + "size": asset["sizeBytes"], + "digest": f"sha256:{asset['sha256']}", + "url": asset["downloadUrl"], + } + for asset in manifest["assets"] + ] + published.append({"name": release_manifest.MANIFEST_NAME, "size": 1}) + + release_manifest.validate_published_assets(manifest, published) + changed = copy.deepcopy(published) + changed[0]["size"] += 1 + with self.assertRaisesRegex(ValueError, "size differs"): + release_manifest.validate_published_assets(manifest, changed) + + missing = published[1:] + with self.assertRaisesRegex(ValueError, "asset set differs"): + release_manifest.validate_published_assets(manifest, missing) + + def test_manifest_output_is_stable_and_excludes_itself(self) -> None: + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp: + root = Path(tmp) + self.write_assets(root) + output = root / release_manifest.MANIFEST_NAME + release_manifest.write_manifest(output, self.build(root)) + first = output.read_bytes() + release_manifest.write_manifest(output, self.build(root)) + + self.assertEqual(first, output.read_bytes()) + payload = json.loads(first) + self.assertNotIn(output.name, [asset["name"] for asset in payload["assets"]]) + + +if __name__ == "__main__": + unittest.main() From 6c02ad1497df992d79d4d37f6d03a0a1f58355ad Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:23:06 +0200 Subject: [PATCH 24/54] Harden release manifest generation --- scripts/release_manifest.py | 86 ++++++++++++++++---------- tests/scripts/test_release_manifest.py | 4 +- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/scripts/release_manifest.py b/scripts/release_manifest.py index 0c0641d2..632514f2 100644 --- a/scripts/release_manifest.py +++ b/scripts/release_manifest.py @@ -232,6 +232,18 @@ def validate_published_assets(manifest: object, published_assets: object) -> Non raise ValueError("Published asset comparison requires a manifest and GitHub asset array") expected = {asset["name"]: asset for asset in manifest["assets"]} + actual = index_published_assets(published_assets) + + if set(actual) != set(expected): + missing = sorted(set(expected) - set(actual)) + unexpected = sorted(set(actual) - set(expected)) + raise ValueError(f"Published release asset set differs from manifest; missing={missing}, unexpected={unexpected}") + + for name, expected_asset in expected.items(): + validate_published_asset(name, expected_asset, actual[name]) + + +def index_published_assets(published_assets: list[object]) -> dict[str, dict[str, object]]: actual: dict[str, dict[str, object]] = {} for asset in published_assets: if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): @@ -242,35 +254,51 @@ def validate_published_assets(manifest: object, published_assets: object) -> Non if name in actual: raise ValueError(f"GitHub release contains a duplicate asset name: {name}") actual[name] = asset + return actual - if set(actual) != set(expected): - missing = sorted(set(expected) - set(actual)) - unexpected = sorted(set(actual) - set(expected)) - raise ValueError(f"Published release asset set differs from manifest; missing={missing}, unexpected={unexpected}") - for name, expected_asset in expected.items(): - actual_asset = actual[name] - digest = str(actual_asset.get("digest") or "") - if digest.startswith("sha256:"): - digest = digest.removeprefix("sha256:") - if actual_asset.get("size") != expected_asset["sizeBytes"]: - raise ValueError(f"Published release asset size differs from manifest: {name}") - if digest != expected_asset["sha256"]: - raise ValueError(f"Published release asset digest differs from manifest: {name}") - if actual_asset.get("url") != expected_asset["downloadUrl"]: - raise ValueError(f"Published release asset URL differs from manifest: {name}") +def validate_published_asset( + name: str, + expected_asset: dict[str, object], + actual_asset: dict[str, object], +) -> None: + digest = str(actual_asset.get("digest") or "").removeprefix("sha256:") + comparisons = ( + ("size", actual_asset.get("size"), expected_asset["sizeBytes"]), + ("digest", digest, expected_asset["sha256"]), + ("URL", actual_asset.get("url"), expected_asset["downloadUrl"]), + ) + for label, actual_value, expected_value in comparisons: + if actual_value != expected_value: + raise ValueError(f"Published release asset {label} differs from manifest: {name}") def validate_asset_entry(asset: object, release: dict[str, object], names: set[str], asset_root: Path | None) -> None: fields = {"name", "platform", "architecture", "packageKind", "sizeBytes", "sha256", "downloadUrl"} if not isinstance(asset, dict) or set(asset) != fields: raise ValueError("Release asset fields do not match schema v1") + name = validate_asset_metadata(asset, release, names) + + if asset_root is not None: + root = asset_root.resolve(strict=True) + matches = [candidate for candidate in root.rglob(name) if candidate.is_file()] + if len(matches) != 1: + raise ValueError(f"Release asset must resolve exactly once beneath the asset root: {name}") + path = matches[0].resolve(strict=True) + if not path.is_relative_to(root) or path.is_symlink() or not path.is_file(): + raise ValueError(f"Release asset is outside the asset root: {name}") + if path.stat().st_size != asset["sizeBytes"] or sha256_file(path) != asset["sha256"]: + raise ValueError(f"Release asset bytes do not match the manifest: {name}") + + +def validate_asset_metadata(asset: dict[str, object], release: dict[str, object], names: set[str]) -> str: name = asset["name"] if not isinstance(name, str) or not name or Path(name).name != name: raise ValueError(f"Unsafe release asset name: {name}") if name == MANIFEST_NAME or name.casefold() in names: raise ValueError(f"Duplicate or self-referencing release asset: {name}") names.add(name.casefold()) + expected_role = classify_asset(name) if tuple(asset[field] for field in ("platform", "architecture", "packageKind")) != expected_role: raise ValueError(f"Release asset classification mismatch: {name}") @@ -283,22 +311,18 @@ def validate_asset_entry(asset: object, release: dict[str, object], names: set[s parsed_url = urlparse(str(asset["downloadUrl"])) if parsed_url.scheme != "https" or parsed_url.hostname != "github.com" or asset["downloadUrl"] != expected_url: raise ValueError(f"Unsafe or inconsistent release asset URL: {name}") - - if asset_root is not None: - root = asset_root.resolve(strict=True) - matches = [candidate for candidate in root.rglob(name) if candidate.is_file()] - if len(matches) != 1: - raise ValueError(f"Release asset must resolve exactly once beneath the asset root: {name}") - path = matches[0].resolve(strict=True) - if not path.is_relative_to(root) or path.is_symlink() or not path.is_file(): - raise ValueError(f"Release asset is outside the asset root: {name}") - if path.stat().st_size != asset["sizeBytes"] or sha256_file(path) != asset["sha256"]: - raise ValueError(f"Release asset bytes do not match the manifest: {name}") + return name -def write_manifest(path: Path, manifest: dict[str, object]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) +def write_manifest(asset_root: Path, manifest: dict[str, object]) -> Path: + root = asset_root.resolve(strict=True) + if not root.is_dir(): + raise ValueError(f"Asset root is not a directory: {root}") + path = (root / MANIFEST_NAME).resolve(strict=False) + if path.parent != root: + raise ValueError("Release manifest target escaped the asset root") path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path def main() -> int: @@ -338,8 +362,8 @@ def main() -> int: include_linux_patches=args.include_linux_patches, include_store_upload=args.include_store_upload, ) - write_manifest(output, manifest) - print(f"Wrote {output} with {len(manifest['assets'])} assets.") + written_path = write_manifest(asset_root, manifest) + print(f"Wrote {written_path} with {len(manifest['assets'])} assets.") elif args.command == "validate": manifest = json.loads(args.manifest.read_text(encoding="utf-8-sig")) validate_manifest(manifest, asset_root=args.asset_root) @@ -350,7 +374,7 @@ def main() -> int: validate_published_assets(manifest, published_assets) print(f"Validated published assets against {args.manifest}.") return 0 - except (OSError, ValueError, json.JSONDecodeError) as error: + except (OSError, ValueError) as error: print(f"Release manifest error: {error}", file=sys.stderr) return 1 diff --git a/tests/scripts/test_release_manifest.py b/tests/scripts/test_release_manifest.py index 08762a1c..53b18d4f 100644 --- a/tests/scripts/test_release_manifest.py +++ b/tests/scripts/test_release_manifest.py @@ -177,9 +177,9 @@ def test_manifest_output_is_stable_and_excludes_itself(self) -> None: root = Path(tmp) self.write_assets(root) output = root / release_manifest.MANIFEST_NAME - release_manifest.write_manifest(output, self.build(root)) + self.assertEqual(output, release_manifest.write_manifest(root, self.build(root))) first = output.read_bytes() - release_manifest.write_manifest(output, self.build(root)) + release_manifest.write_manifest(root, self.build(root)) self.assertEqual(first, output.read_bytes()) payload = json.loads(first) From 7e4cf6da731aeb67e032c6bde128f875bc3018d7 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:29:55 +0200 Subject: [PATCH 25/54] Verify updates against canonical manifests --- CHANGELOG.md | 2 +- ROADMAP.md | 2 +- docs/RELEASE_1.8.7.md | 9 +- docs/RELEASING.md | 26 +++ docs/UPDATER.md | 16 +- .../Services/GitHubUpdateService.cs | 124 ++++++++--- .../Services/ReleaseManifestVerifier.cs | 210 ++++++++++++++++++ .../ReleaseManifestVerifierTests.cs | 120 ++++++++++ 8 files changed, 477 insertions(+), 32 deletions(-) create mode 100644 src/VaultSync.UI/Services/ReleaseManifestVerifier.cs create mode 100644 tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcae23e..25726483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [1.8.7] - Unreleased ### Added -- [VS-1872] Added the versioned canonical release manifest generator and schema, with exact artifact sizes, SHA-256 digests, official download identities, strict platform-matrix validation, deterministic output, and release-workflow generation from the final Windows, macOS, and Linux artifacts. +- [VS-1872] Added the versioned canonical release manifest generator and schema, with exact artifact sizes, SHA-256 digests, official download identities, strict platform-matrix validation, deterministic output, release-workflow generation, post-publish verification, and fail-closed updater consumption across Windows, macOS, and Linux artifacts. - [VS-1877] Added a durable, owner-private installation identity for cross-machine coordination without treating mutable host names or telemetry identifiers as writer identity. - [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. diff --git a/ROADMAP.md b/ROADMAP.md index aabcb1e0..08a4a561 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ that exists only on the release branch or remains planned. - Acceptance: a user or support bundle can identify the exact running build without inspecting filenames, and unavailable values are shown as unknown rather than guessed. -- [ ] `VS-1872` `P0` Publish artifact checksums and a machine-readable release +- [x] `VS-1872` `P0` Publish artifact checksums and a machine-readable release manifest from one release source of truth. - Scope: generate version, channel, tag, commit, compatible predecessors, asset names, platform, architecture, package kind, byte size, and SHA-256 diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 568be64d..07ff850e 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -51,7 +51,10 @@ assumed and must be approved explicitly if the release needs one. - The canonical release-manifest v1 schema, deterministic generator, artifact classifier, exact size/SHA-256 verification, and complete platform-matrix gate are implemented. Release automation generates the manifest only after - all direct-download platform artifacts have been built and collected. + all direct-download platform artifacts have been built and collected. The + post-publish gate and desktop updater consume the same schema; the updater + rejects a release when its manifest identity or any GitHub asset name, URL, + size, or digest disagrees. These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -62,8 +65,8 @@ branch package. 1. Surface repository status and an explicit stale-takeover decision in UI. 2. Make metadata imports previewable, versioned, durable, and reversible. -3. Connect the canonical manifest to post-publish verification and expose its - release identity through the app and CLI. +3. Expose the running build and canonical release identity through the app, + CLI, diagnostics, and support exports. 4. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB behavior before enabling supported concurrent-machine workflows. 5. Reduce codebase duplication and oversized orchestration through shared, diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 385afa8a..0940cb97 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -115,6 +115,32 @@ The post-publish readiness gate downloads the manifest and compares it with GitHub's live asset metadata. Any missing or unexpected name, byte-size change, digest change, unsafe URL, or schema mismatch blocks the release. +### Offline checksum verification + +Download `vaultsync-release-manifest.json` and the package to verify into the +same directory. This macOS command reads the expected SHA-256 and checks the +local bytes without trusting the package filename supplied by a different +source (`sha256sum -c -` is the equivalent final command on Linux): + +```bash +asset="VaultSync-1.8.7-linux-x64.tar.gz" +expected="$(jq -er --arg name "$asset" '.assets[] | select(.name == $name) | .sha256' vaultsync-release-manifest.json)" +printf '%s %s\n' "$expected" "$asset" | shasum -a 256 -c - +``` + +On Windows PowerShell: + +```powershell +$asset = "VaultSync-Setup-1.8.7.exe" +$manifest = Get-Content .\vaultsync-release-manifest.json -Raw | ConvertFrom-Json +$expected = ($manifest.assets | Where-Object name -eq $asset).sha256 +$actual = (Get-FileHash ".\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not $expected -or $actual -cne $expected) { throw "SHA-256 verification failed for $asset" } +``` + +Before using a checksum, confirm the manifest itself came from the matching tag +on the official `ATAC-Helicopter/VaultSync` GitHub Releases page. + ## 5) Release Checklist - Run the release gate before publishing: ```powershell diff --git a/docs/UPDATER.md b/docs/UPDATER.md index d4c55ae6..82c3989f 100644 --- a/docs/UPDATER.md +++ b/docs/UPDATER.md @@ -7,6 +7,8 @@ VaultSync uses GitHub Releases for update discovery and supports patch assets to - Beta/Dev: prerelease-capable flow for `Dev` branch builds that use a prerelease suffix (when enabled in app settings). ## Required Release Assets +- Canonical release manifest: + - `vaultsync-release-manifest.json` - Patch manifest: - `vaultsync-patch-.json` - Patch archive: @@ -36,6 +38,13 @@ Linux can use architecture-specific patch names: ## Runtime Expectations - Updater checks according to Settings policy. +- A newer release is offered only after its canonical manifest is downloaded + from the official GitHub release, matched to the exact release tag and + channel, and reconciled with GitHub's complete asset list. +- Asset selection uses the manifest's official URL, exact byte size, and + SHA-256. A missing manifest, unsupported schema, duplicate or unexpected + asset, unsafe URL, or metadata mismatch fails closed instead of presenting an + unverified download. - Patch apply does not replace user config/data. - A failed in-process replacement restores overwritten files and removes newly created files before reporting failure. - Full power-loss atomicity requires a future directory-level installer transaction; until then, release qualification must exercise interrupted updates and retain full-installer recovery. @@ -53,12 +62,17 @@ This is required because patch archives do not remove obsolete files. The automa ## Release Validation After publishing assets, verify: -- manifest resolves correctly +- canonical release manifest resolves and passes schema v1 validation +- every GitHub asset name, URL, size, and digest matches that manifest exactly - patch downloads succeed - patch apply succeeds on target platform - installer fallback remains functional - the single base version listed in `baseVersions` was validated against that same patch payload +The updater and `scripts/release_readiness_gate.ps1 -Phase PostPublish` enforce +the same canonical release-manifest contract. Patch manifests remain a separate +payload-level contract describing the files inside one platform patch. + ## Related Docs - `docs/RELEASING.md` - `docs/wiki/Updates.md` diff --git a/src/VaultSync.UI/Services/GitHubUpdateService.cs b/src/VaultSync.UI/Services/GitHubUpdateService.cs index b52e0c80..ce8f05f4 100644 --- a/src/VaultSync.UI/Services/GitHubUpdateService.cs +++ b/src/VaultSync.UI/Services/GitHubUpdateService.cs @@ -7,6 +7,8 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -111,6 +113,7 @@ public sealed class GitHubUpdateService private const int MaxReleasePages = 1; private const string StableBranchName = "stable"; private const string DevBranchName = "dev"; + private const int MaxReleaseManifestBytes = 1024 * 1024; private static readonly HttpClient s_httpClient = CreateHttpClient(); private static readonly object s_releaseCacheLock = new(); @@ -191,8 +194,19 @@ public static async Task CheckForUpdateAsync( string releaseNotes = candidate.Body ?? string.Empty; DateTime publishedAt = candidate.PublishedAt ?? DateTime.MinValue; - (string? manifestUrl, string? manifestSha256, long manifestSize, Uri? archiveUrl, string? archiveName, string? archiveSha256, long archiveSize) = GetPatchAssets(candidate.Assets); - (Uri? installerUrl, string? installerName, string? installerSha256, long installerSize) = GetInstallerAsset(candidate.Assets); + IReadOnlyCollection? verifiedAssets = await GetVerifiedReleaseAssetsAsync( + candidate, + releaseTag, + cancellationToken).ConfigureAwait(false); + if (verifiedAssets is null) + { + Console.WriteLine("[Update] Candidate release manifest is missing or inconsistent with GitHub assets."); + diagnostics.Decision = "candidate-release-manifest-invalid"; + return new UpdateCheckEvaluation(null, diagnostics); + } + + (string? manifestUrl, string? manifestSha256, long manifestSize, Uri? archiveUrl, string? archiveName, string? archiveSha256, long archiveSize) = GetPatchAssets(verifiedAssets); + (Uri? installerUrl, string? installerName, string? installerSha256, long installerSize) = GetInstallerAsset(verifiedAssets); diagnostics.SelectedCandidate = ToDiagnostics(candidate, !string.IsNullOrWhiteSpace(manifestUrl) && archiveUrl != null, installerUrl != null); diagnostics.Decision = channel == GitHubReleaseChannel.Beta ? "beta-or-stable-candidate-selected" @@ -433,9 +447,71 @@ private sealed class GitHubAsset public string? Digest { get; set; } } - private static (string? ManifestUrl, string? ManifestSha256, long ManifestSize, Uri? ArchiveUrl, string? ArchiveName, string? ArchiveSha256, long ArchiveSize) GetPatchAssets(List? assets) + private static async Task?> GetVerifiedReleaseAssetsAsync( + GitHubRelease release, + string releaseTag, + CancellationToken cancellationToken) { - if (assets is null || assets.Count == 0) + List manifestAssets = release.Assets? + .Where(asset => string.Equals(asset.Name, ReleaseManifestVerifier.ManifestName, StringComparison.Ordinal)) + .ToList() ?? []; + if (manifestAssets.Count != 1) + return null; + + GitHubAsset manifestAsset = manifestAssets[0]; + if (manifestAsset.Size <= 0 || + manifestAsset.Size > MaxReleaseManifestBytes || + !TryGetTrustedReleaseAssetUri(manifestAsset.BrowserDownloadUrl, out Uri? manifestUri) || + manifestUri is null || + !string.Equals( + manifestUri.AbsoluteUri, + $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{releaseTag}/{ReleaseManifestVerifier.ManifestName}", + StringComparison.Ordinal) || + TryParseSha256Digest(manifestAsset.Digest) is not { } expectedHash) + { + return null; + } + + try + { + byte[] bytes = await s_httpClient.GetByteArrayAsync(manifestUri, cancellationToken).ConfigureAwait(false); + if (bytes.LongLength != manifestAsset.Size || bytes.Length > MaxReleaseManifestBytes) + return null; + + string actualHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!string.Equals(actualHash, expectedHash, StringComparison.Ordinal)) + return null; + + var publishedAssets = release.Assets! + .Where(asset => asset.Name is not null) + .Select(asset => new PublishedReleaseAsset( + asset.Name!, + asset.BrowserDownloadUrl, + asset.Size, + asset.Digest)) + .ToList(); + return ReleaseManifestVerifier.TryValidate( + Encoding.UTF8.GetString(bytes), + releaseTag, + release.Prerelease, + publishedAssets, + out IReadOnlyDictionary assets) + ? assets.Values.ToList() + : null; + } + catch (HttpRequestException) + { + return null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + private static (string? ManifestUrl, string? ManifestSha256, long ManifestSize, Uri? ArchiveUrl, string? ArchiveName, string? ArchiveSha256, long ArchiveSize) GetPatchAssets(IReadOnlyCollection assets) + { + if (assets.Count == 0) return (null, null, 0, null, null, null, 0); List suffixes = GetPlatformSuffixes(); @@ -447,35 +523,35 @@ private static (string? ManifestUrl, string? ManifestSha256, long ManifestSize, string manifestName = $"vaultsync-patch-{platformSuffix}.json"; string archiveName = $"vaultsync-patch-{platformSuffix}.zip"; - GitHubAsset? manifest = assets.FirstOrDefault(a => string.Equals(a.Name, manifestName, StringComparison.OrdinalIgnoreCase)); - GitHubAsset? archive = assets.FirstOrDefault(a => string.Equals(a.Name, archiveName, StringComparison.OrdinalIgnoreCase)); + ReleaseManifestAsset? manifest = assets.FirstOrDefault(a => string.Equals(a.Name, manifestName, StringComparison.OrdinalIgnoreCase)); + ReleaseManifestAsset? archive = assets.FirstOrDefault(a => string.Equals(a.Name, archiveName, StringComparison.OrdinalIgnoreCase)); if (manifest is null || archive is null || - !TryGetTrustedReleaseAssetUri(manifest.BrowserDownloadUrl, out Uri? manifestUri)) + !TryGetTrustedReleaseAssetUri(manifest.DownloadUrl, out Uri? manifestUri)) continue; - if (!TryGetTrustedReleaseAssetUri(archive.BrowserDownloadUrl, out Uri? archiveUri)) + if (!TryGetTrustedReleaseAssetUri(archive.DownloadUrl, out Uri? archiveUri)) continue; return ( manifestUri!.AbsoluteUri, - TryParseSha256Digest(manifest.Digest), - manifest.Size, + manifest.Sha256, + manifest.SizeBytes, archiveUri, archive.Name, - TryParseSha256Digest(archive.Digest), - archive.Size); + archive.Sha256, + archive.SizeBytes); } return (null, null, 0, null, null, null, 0); } - private static (Uri? InstallerUrl, string? InstallerName, string? InstallerSha256, long InstallerSize) GetInstallerAsset(List? assets) + private static (Uri? InstallerUrl, string? InstallerName, string? InstallerSha256, long InstallerSize) GetInstallerAsset(IReadOnlyCollection assets) { - if (assets is null || assets.Count == 0) + if (assets.Count == 0) return (null, null, null, 0); - GitHubAsset? asset = null; + ReleaseManifestAsset? asset = null; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -504,13 +580,11 @@ a.Name is not null && asset ??= preferDebianPackage ? null : FindLinuxAsset(assets, ".deb"); } - if (asset is null || string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) + if (asset is null || string.IsNullOrWhiteSpace(asset.DownloadUrl)) return (null, null, null, 0); - string? sha256 = TryParseSha256Digest(asset.Digest); - - return TryGetTrustedReleaseAssetUri(asset.BrowserDownloadUrl, out Uri? url) - ? (url, asset.Name, sha256, asset.Size) + return TryGetTrustedReleaseAssetUri(asset.DownloadUrl, out Uri? url) + ? (url, asset.Name, asset.Sha256, asset.SizeBytes) : (null, null, null, 0); } @@ -545,11 +619,11 @@ internal static bool TryGetTrustedReleaseAssetUri(string? value, out Uri? uri) : null; } - private static GitHubAsset? FindLinuxAsset(List assets, string extension) + private static ReleaseManifestAsset? FindLinuxAsset(IReadOnlyCollection assets, string extension) { foreach (string suffix in GetLinuxAssetSuffixes()) { - GitHubAsset? asset = assets.FirstOrDefault(a => + ReleaseManifestAsset? asset = assets.FirstOrDefault(a => a.Name is not null && a.Name.Contains(suffix, StringComparison.OrdinalIgnoreCase) && a.Name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)); @@ -645,16 +719,14 @@ private static UpdateReleaseCandidateDiagnostics ToDiagnostics(GitHubRelease? re if (release is null) return new UpdateReleaseCandidateDiagnostics(); - (string? manifestUrl, string? _, long _, Uri? archiveUrl, string? _, string? _, long _) = GetPatchAssets(release.Assets); - (Uri? installerUrl, string? _, string? _, long _) = GetInstallerAsset(release.Assets); return new UpdateReleaseCandidateDiagnostics { Tag = (release.TagName ?? string.Empty).Trim(), TargetCommitish = (release.TargetCommitish ?? string.Empty).Trim(), Prerelease = release.Prerelease, PublishedUtc = release.PublishedAt?.ToUniversalTime().ToString("O") ?? string.Empty, - HasPatch = hasPatch ?? (!string.IsNullOrWhiteSpace(manifestUrl) && archiveUrl != null), - HasInstaller = hasInstaller ?? installerUrl != null + HasPatch = hasPatch ?? false, + HasInstaller = hasInstaller ?? false }; } } diff --git a/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs b/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs new file mode 100644 index 00000000..c8ede2f8 --- /dev/null +++ b/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace VaultSync.UI.Services +{ + internal sealed record PublishedReleaseAsset(string Name, string? DownloadUrl, long Size, string? Digest); + + internal sealed class ReleaseManifestAsset + { + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("platform")] + public string? Platform { get; init; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; init; } + + [JsonPropertyName("packageKind")] + public string? PackageKind { get; init; } + + [JsonPropertyName("sizeBytes")] + public long SizeBytes { get; init; } + + [JsonPropertyName("sha256")] + public string? Sha256 { get; init; } + + [JsonPropertyName("downloadUrl")] + public string? DownloadUrl { get; init; } + } + + internal static class ReleaseManifestVerifier + { + internal const string ManifestName = "vaultsync-release-manifest.json"; + private const string Repository = "ATAC-Helicopter/VaultSync"; + private const int SchemaVersion = 1; + private static readonly Regex s_versionPattern = new( + "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", + RegexOptions.CultureInvariant); + private static readonly HashSet s_platforms = ["windows", "macos", "linux"]; + private static readonly HashSet s_architectures = ["x64", "arm64"]; + private static readonly HashSet s_packageKinds = + [ + "installer", "store-upload", "disk-image", "archive", "debian-package", + "appimage", "patch-manifest", "patch-archive" + ]; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + + internal static bool TryValidate( + string json, + string releaseTag, + bool prerelease, + IReadOnlyCollection publishedAssets, + out IReadOnlyDictionary assets) + { + assets = new Dictionary(); + ReleaseManifestDocument? manifest; + try + { + manifest = JsonSerializer.Deserialize(json, s_jsonOptions); + } + catch (JsonException) + { + return false; + } + + if (!HasValidIdentity(manifest, releaseTag, prerelease) || manifest!.Assets is not { Count: > 0 }) + return false; + + Dictionary? published = IndexPublishedAssets(publishedAssets); + if (published is null || published.Count != manifest.Assets.Count) + return false; + + var verified = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ReleaseManifestAsset asset in manifest.Assets) + { + if (!TryValidateAsset(asset, releaseTag, published, out string name) || !verified.TryAdd(name, asset)) + return false; + } + + if (verified.Count != published.Count || verified.Keys.Any(name => !published.ContainsKey(name))) + return false; + + assets = verified; + return true; + } + + private static bool HasValidIdentity(ReleaseManifestDocument? manifest, string releaseTag, bool prerelease) + { + ReleaseManifestIdentity? release = manifest?.Release; + string expectedChannel = prerelease ? "beta" : "stable"; + if (manifest?.SchemaVersion != SchemaVersion || + release is null || + release.Version is null || + !s_versionPattern.IsMatch(release.Version) || + !HasValidPredecessors(release)) + { + return false; + } + + return + string.Equals(release.Repository, Repository, StringComparison.Ordinal) && + string.Equals(release.Tag, releaseTag, StringComparison.Ordinal) && + string.Equals($"v{release.Version}", releaseTag, StringComparison.Ordinal) && + string.Equals(release.Channel, expectedChannel, StringComparison.Ordinal) && + (prerelease == release.Version.Contains('-', StringComparison.Ordinal)) && + IsLowerHex(release.Commit, 40); + } + + private static bool HasValidPredecessors(ReleaseManifestIdentity release) + { + if (release.CompatiblePredecessors is not { Count: > 0 }) + return false; + + var unique = new HashSet(StringComparer.Ordinal); + return release.CompatiblePredecessors.All(version => + s_versionPattern.IsMatch(version) && + !string.Equals(version, release.Version, StringComparison.Ordinal) && + unique.Add(version)); + } + + private static Dictionary? IndexPublishedAssets( + IReadOnlyCollection publishedAssets) + { + var indexed = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (PublishedReleaseAsset asset in publishedAssets) + { + if (string.Equals(asset.Name, ManifestName, StringComparison.OrdinalIgnoreCase)) + continue; + if (string.IsNullOrWhiteSpace(asset.Name) || !indexed.TryAdd(asset.Name, asset)) + return null; + } + return indexed; + } + + private static bool TryValidateAsset( + ReleaseManifestAsset asset, + string releaseTag, + IReadOnlyDictionary published, + out string name) + { + name = asset.Name ?? string.Empty; + if (string.IsNullOrWhiteSpace(name) || + name.IndexOfAny(['/', '\\']) >= 0 || + asset.SizeBytes <= 0 || + !IsLowerHex(asset.Sha256, 64) || + asset.Platform is null || !s_platforms.Contains(asset.Platform) || + asset.Architecture is null || !s_architectures.Contains(asset.Architecture) || + asset.PackageKind is null || !s_packageKinds.Contains(asset.PackageKind) || + !published.TryGetValue(name, out PublishedReleaseAsset? publishedAsset)) + { + return false; + } + + string expectedUrl = $"https://github.com/{Repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(name)}"; + string? digest = GitHubUpdateService.TryParseSha256Digest(publishedAsset.Digest); + return string.Equals(asset.DownloadUrl, expectedUrl, StringComparison.Ordinal) && + string.Equals(publishedAsset.DownloadUrl, expectedUrl, StringComparison.Ordinal) && + publishedAsset.Size == asset.SizeBytes && + string.Equals(digest, asset.Sha256, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLowerHex(string? value, int length) => + value is not null && + value.Length == length && + value.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + private sealed class ReleaseManifestDocument + { + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("release")] + public ReleaseManifestIdentity? Release { get; init; } + + [JsonPropertyName("assets")] + public List? Assets { get; init; } + } + + private sealed class ReleaseManifestIdentity + { + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("channel")] + public string? Channel { get; init; } + + [JsonPropertyName("tag")] + public string? Tag { get; init; } + + [JsonPropertyName("commit")] + public string? Commit { get; init; } + + [JsonPropertyName("repository")] + public string? Repository { get; init; } + + [JsonPropertyName("compatiblePredecessors")] + public List? CompatiblePredecessors { get; init; } + } + } +} diff --git a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs new file mode 100644 index 00000000..b4cd6e3f --- /dev/null +++ b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Text.Json; +using VaultSync.UI.Services; +using Xunit; + +namespace VaultSync.Core.Tests; + +public sealed class ReleaseManifestVerifierTests +{ + private const string AssetName = "VaultSync-1.8.7-linux-x64.tar.gz"; + private const string Hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private const string Tag = "v1.8.7"; + private const string Url = "https://github.com/ATAC-Helicopter/VaultSync/releases/download/v1.8.7/VaultSync-1.8.7-linux-x64.tar.gz"; + + [Fact] + public void ExactPublishedManifest_IsAccepted() + { + bool valid = ReleaseManifestVerifier.TryValidate( + CreateManifest(), + Tag, + prerelease: false, + CreatePublishedAssets(), + out IReadOnlyDictionary assets); + + Assert.True(valid); + ReleaseManifestAsset asset = Assert.Single(assets).Value; + Assert.Equal(Hash, asset.Sha256); + Assert.Equal(Url, asset.DownloadUrl); + } + + [Theory] + [InlineData(11, Hash, Url)] + [InlineData(10, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Url)] + [InlineData(10, Hash, "https://evil.example/VaultSync.tar.gz")] + public void PublishedMetadataMismatch_IsRejected(long size, string hash, string url) + { + List published = CreatePublishedAssets(size, hash, url); + + Assert.False(ReleaseManifestVerifier.TryValidate( + CreateManifest(), Tag, prerelease: false, published, out _)); + } + + [Theory] + [InlineData("v1.8.8", false)] + [InlineData(Tag, true)] + public void ReleaseIdentityMismatch_IsRejected(string tag, bool prerelease) + { + Assert.False(ReleaseManifestVerifier.TryValidate( + CreateManifest(), tag, prerelease, CreatePublishedAssets(), out _)); + } + + [Fact] + public void UnknownSchemaFields_AreRejected() + { + string json = CreateManifest().Replace("\"schemaVersion\":1", "\"schemaVersion\":1,\"unexpected\":true"); + + Assert.False(ReleaseManifestVerifier.TryValidate( + json, Tag, prerelease: false, CreatePublishedAssets(), out _)); + } + + [Fact] + public void UnsupportedSchemaVersion_IsRejected() + { + string json = CreateManifest().Replace("\"schemaVersion\":1", "\"schemaVersion\":2"); + + Assert.False(ReleaseManifestVerifier.TryValidate( + json, Tag, prerelease: false, CreatePublishedAssets(), out _)); + } + + [Fact] + public void AssetsMissingFromGitHub_AreRejected() + { + Assert.False(ReleaseManifestVerifier.TryValidate( + CreateManifest(), Tag, prerelease: false, [], out _)); + } + + private static string CreateManifest() + { + var manifest = new + { + schemaVersion = 1, + release = new + { + version = "1.8.7", + channel = "stable", + tag = Tag, + commit = new string('a', 40), + repository = "ATAC-Helicopter/VaultSync", + compatiblePredecessors = new[] { "1.8.6" } + }, + assets = new[] + { + new + { + name = AssetName, + platform = "linux", + architecture = "x64", + packageKind = "archive", + sizeBytes = 10, + sha256 = Hash, + downloadUrl = Url + } + } + }; + return JsonSerializer.Serialize(manifest); + } + + private static List CreatePublishedAssets( + long size = 10, + string hash = Hash, + string url = Url) => + [ + new(AssetName, url, size, $"sha256:{hash}"), + new( + ReleaseManifestVerifier.ManifestName, + $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{Tag}/{ReleaseManifestVerifier.ManifestName}", + 100, + $"sha256:{Hash}") + ]; +} From 9ffaa6d7fa0521230b64c5078927d93717edff80 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:41:04 +0200 Subject: [PATCH 26/54] Bound release manifest version parsing --- .../Services/ReleaseManifestVerifier.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs b/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs index c8ede2f8..ee3bd86e 100644 --- a/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs +++ b/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs @@ -33,14 +33,11 @@ internal sealed class ReleaseManifestAsset public string? DownloadUrl { get; init; } } - internal static class ReleaseManifestVerifier + internal static partial class ReleaseManifestVerifier { internal const string ManifestName = "vaultsync-release-manifest.json"; - private const string Repository = "ATAC-Helicopter/VaultSync"; - private const int SchemaVersion = 1; - private static readonly Regex s_versionPattern = new( - "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", - RegexOptions.CultureInvariant); + private const string OfficialRepository = "ATAC-Helicopter/VaultSync"; + private const int SupportedSchemaVersion = 1; private static readonly HashSet s_platforms = ["windows", "macos", "linux"]; private static readonly HashSet s_architectures = ["x64", "arm64"]; private static readonly HashSet s_packageKinds = @@ -98,17 +95,17 @@ private static bool HasValidIdentity(ReleaseManifestDocument? manifest, string r { ReleaseManifestIdentity? release = manifest?.Release; string expectedChannel = prerelease ? "beta" : "stable"; - if (manifest?.SchemaVersion != SchemaVersion || + if (manifest?.SchemaVersion != SupportedSchemaVersion || release is null || release.Version is null || - !s_versionPattern.IsMatch(release.Version) || + !VersionPattern().IsMatch(release.Version) || !HasValidPredecessors(release)) { return false; } return - string.Equals(release.Repository, Repository, StringComparison.Ordinal) && + string.Equals(release.Repository, OfficialRepository, StringComparison.Ordinal) && string.Equals(release.Tag, releaseTag, StringComparison.Ordinal) && string.Equals($"v{release.Version}", releaseTag, StringComparison.Ordinal) && string.Equals(release.Channel, expectedChannel, StringComparison.Ordinal) && @@ -123,7 +120,7 @@ private static bool HasValidPredecessors(ReleaseManifestIdentity release) var unique = new HashSet(StringComparer.Ordinal); return release.CompatiblePredecessors.All(version => - s_versionPattern.IsMatch(version) && + VersionPattern().IsMatch(version) && !string.Equals(version, release.Version, StringComparison.Ordinal) && unique.Add(version)); } @@ -145,7 +142,7 @@ private static bool HasValidPredecessors(ReleaseManifestIdentity release) private static bool TryValidateAsset( ReleaseManifestAsset asset, string releaseTag, - IReadOnlyDictionary published, + Dictionary published, out string name) { name = asset.Name ?? string.Empty; @@ -161,7 +158,7 @@ private static bool TryValidateAsset( return false; } - string expectedUrl = $"https://github.com/{Repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(name)}"; + string expectedUrl = $"https://github.com/{OfficialRepository}/releases/download/{releaseTag}/{Uri.EscapeDataString(name)}"; string? digest = GitHubUpdateService.TryParseSha256Digest(publishedAsset.Digest); return string.Equals(asset.DownloadUrl, expectedUrl, StringComparison.Ordinal) && string.Equals(publishedAsset.DownloadUrl, expectedUrl, StringComparison.Ordinal) && @@ -174,6 +171,9 @@ value is not null && value.Length == length && value.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f'); + [GeneratedRegex("^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] + private static partial Regex VersionPattern(); + private sealed class ReleaseManifestDocument { [JsonPropertyName("schemaVersion")] From 8b30326d27107bb838488fff072c4ba9a3716457 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:35:21 +0200 Subject: [PATCH 27/54] Ignore Blueprints local project state --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index e46fc10a..9d522bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,9 @@ docs/localization-missing-keys-report.md docs/PROJECT_OPERATIONS.md .codex_tmp_merge_locales.cs docs/video/build/ + +# Blueprints local project state (generated when this repository is added) +/.blueprints/ +/log/ +/project/ +/versions From e24b48aec584455e38e16b40c53731a34ce3a009 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:26:21 +0200 Subject: [PATCH 28/54] Modernize utility window layouts --- .../Views/MetadataSyncReviewWindow.axaml | 178 ++++++++---- .../Views/SnapshotExplorerView.axaml | 256 +++++++++--------- src/VaultSync.UI/Views/UpdaterWindow.axaml | 124 +++++---- 3 files changed, 339 insertions(+), 219 deletions(-) diff --git a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml index 752a2001..9a193472 100644 --- a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml +++ b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml @@ -6,62 +6,142 @@ x:Class="VaultSync.UI.Views.MetadataSyncReviewWindow" x:DataType="vm:MetadataSyncReviewViewModel" Title="{infra:LocalizedString Key=MetadataSync.Review.Title}" - Width="520" - Height="420" - MinWidth="480" - MinHeight="360" + Width="680" + Height="560" + MinWidth="560" + MinHeight="480" WindowStartupLocation="CenterOwner" Opened="OnWindowOpened" Closing="OnWindowClosing"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + + + + + + + + + + + + + + + + diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs index 57de6d2d..d6e90c2a 100644 --- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs +++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs @@ -320,6 +320,37 @@ public void ExpiredOwnerCannotRenewLease() Assert.Equal(RepositoryLeaseState.Stale, service.Inspect(root.Path).State); } + [Fact] + public void ClockSkewTolerance_DoesNotTreatRecentExpiryOrFutureHeartbeatAsTakeoverPermission() + { + using var root = new TempDirectory(); + var clock = new ManualTimeProvider(new DateTimeOffset(2026, 8, 16, 12, 0, 0, TimeSpan.Zero)); + var service = new RepositoryLeaseService(clock, TimeSpan.FromMinutes(2)); + using RepositoryLeaseHandle owner = AssertAcquired( + service.TryAcquire(root.Path, CreateRequest("metadata-export"))); + + clock.Advance(TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(30))); + Assert.Equal(RepositoryLeaseState.Active, service.Inspect(root.Path).State); + + clock.Advance(TimeSpan.FromMinutes(1).Add(TimeSpan.FromSeconds(31))); + Assert.Equal(RepositoryLeaseState.Stale, service.Inspect(root.Path).State); + } + + [Fact] + public void LostRepository_InvalidatesWriterWithoutRecreatingCoordinationState() + { + using var root = new TempDirectory(); + var service = new RepositoryLeaseService(); + using RepositoryLeaseHandle owner = AssertAcquired( + service.TryAcquire(root.Path, CreateRequest("metadata-export"))); + + Directory.Delete(root.Path, recursive: true); + + Assert.False(owner.Renew()); + Assert.False(owner.IsOwner); + Assert.False(File.Exists(RepositoryLeaseService.GetDatabasePath(root.Path))); + } + [Fact] public void InvalidRepositoryRootFailsClosedWithoutThrowing() { diff --git a/tests/VaultSync.Core.Tests/RepositoryWriterUiTests.cs b/tests/VaultSync.Core.Tests/RepositoryWriterUiTests.cs new file mode 100644 index 00000000..cdec0a32 --- /dev/null +++ b/tests/VaultSync.Core.Tests/RepositoryWriterUiTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading.Tasks; +using VaultSync.Core.Config; +using VaultSync.Core.Services; +using VaultSync.Core.Tests.TestSupport; +using VaultSync.UI; +using VaultSync.UI.Services; +using Xunit; + +namespace VaultSync.Core.Tests; + +public sealed class RepositoryWriterUiTests +{ + [Fact] + public void WriterInspection_ShowsShortOwnerEvidenceAndOnlyOffersStaleReview() + { + var destination = new BackupDestinationViewModel(); + var lease = new RepositoryLeaseSnapshot( + RepositoryLeaseService.CurrentProtocolVersion, + "1234567890abcdef1234567890abcdef", + "Studio Mac", + 42, + "metadata-export", + "abcdefabcdefabcdefabcdefabcdefab", + "1.8.7", + new DateTimeOffset(2026, 8, 16, 10, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 8, 16, 10, 1, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 8, 16, 10, 6, 0, TimeSpan.Zero)); + + SettingsViewModel.ApplyRepositoryWriterInspection( + destination, + new RepositoryLeaseInspection(RepositoryLeaseState.Active, lease, "busy"), + "/shared/repository"); + + Assert.Equal("In use", destination.RepositoryWriterStatus); + Assert.Contains("Studio Mac", destination.RepositoryWriterDetails, StringComparison.Ordinal); + Assert.Contains("identity 12345678", destination.RepositoryWriterDetails, StringComparison.Ordinal); + Assert.Contains("metadata-export", destination.RepositoryWriterDetails, StringComparison.Ordinal); + Assert.DoesNotContain(lease.InstallationId, destination.RepositoryWriterDetails, StringComparison.Ordinal); + Assert.False(destination.CanReviewStaleWriter); + + SettingsViewModel.ApplyRepositoryWriterInspection( + destination, + new RepositoryLeaseInspection(RepositoryLeaseState.Stale, lease, "stale"), + "/shared/repository"); + + Assert.Equal("Needs review", destination.RepositoryWriterStatus); + Assert.True(destination.CanReviewStaleWriter); + Assert.Equal(lease.Nonce, destination.StaleWriterNonce); + Assert.Equal("/shared/repository", destination.InspectedRepositoryRoot); + + SettingsViewModel.ApplyRepositoryWriterInspection( + destination, + new RepositoryLeaseInspection(RepositoryLeaseState.Available, null, "available")); + + Assert.Equal("Available", destination.RepositoryWriterStatus); + Assert.False(destination.CanReviewStaleWriter); + Assert.Empty(destination.StaleWriterNonce); + Assert.Empty(destination.InspectedRepositoryRoot); + } + + [Fact] + public async Task ConfirmedStaleWriterTakeover_PreservesEvidenceAndReturnsRepositoryToAvailable() + { + using var configScope = new TestAppConfigScope(); + using var repository = new TempDirectory(); + AppConfigStore.Save(new AppConfig()); + + var clock = new ManualTimeProvider( + new DateTimeOffset(2026, 8, 16, 10, 0, 0, TimeSpan.Zero)); + var leaseService = new RepositoryLeaseService(clock, TimeSpan.Zero); + RepositoryLeaseAcquireResult acquired = leaseService.TryAcquire( + repository.Path, + new RepositoryLeaseRequest( + Guid.NewGuid().ToString("N"), + "Other machine", + "metadata-export", + "1.8.7", + TimeSpan.FromMinutes(5))); + using RepositoryLeaseHandle oldWriter = Assert.IsType(acquired.Handle); + clock.Advance(TimeSpan.FromMinutes(6)); + + var settings = new SettingsViewModel( + new LocalizationService(), + repositoryLeaseService: leaseService, + installationIdentityProvider: new FixedIdentityProvider(), + appVersion: "1.8.7"); + var destination = new BackupDestinationViewModel + { + Alias = "Shared", + Path = repository.Path, + PreMounted = true, + EnableMetadataSync = true + }; + + await settings.InspectRepositoryWriterAsync(destination); + + Assert.True(destination.CanReviewStaleWriter); + Assert.Equal(oldWriter.Lease.Nonce, destination.StaleWriterNonce); + + await settings.ConfirmStaleWriterTakeoverAsync(destination); + + Assert.Equal("Available", destination.RepositoryWriterStatus); + Assert.False(destination.CanReviewStaleWriter); + Assert.Contains("preserved as evidence", destination.RepositoryWriterDetails, StringComparison.Ordinal); + RepositoryLeaseEvidence evidence = Assert.Single(RepositoryLeaseService.ListEvidence(repository.Path)); + Assert.Equal(oldWriter.Lease.Nonce, evidence.Nonce); + Assert.Equal("stale-takeover", evidence.Disposition); + Assert.False(oldWriter.IsOwner); + } + + private sealed class FixedIdentityProvider : IInstallationIdentityProvider + { + public string GetOrCreate() => "fedcba0987654321fedcba0987654321"; + } + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); + } +} From 64c001e028814927ab3f41afbc619840282564c8 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:56:39 +0200 Subject: [PATCH 46/54] Set the two-week minor release cadence --- CHANGELOG.md | 3 --- ROADMAP.md | 8 +++++++- docs/RELEASE_1.8.7.md | 20 +++++++++++++++++++- docs/RELEASING.md | 13 +++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ed0bdf..d4efab70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,10 @@ # Changelog ## [1.8.7] - Unreleased - ### Added - [VS-1872] Added the versioned canonical release manifest generator and schema, with exact artifact sizes, SHA-256 digests, official download identities, strict platform-matrix validation, deterministic output, release-workflow generation, post-publish verification, and fail-closed updater consumption across Windows, macOS, and Linux artifacts. - [VS-1877] Added a durable, owner-private installation identity for cross-machine coordination without treating mutable host names or telemetry identifiers as writer identity. - [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. - [VS-1877] Added per-destination repository-writer inspection and an explicit stale-takeover review that shows the owner, operation, version, heartbeat, and expiry before preserving the old lease as evidence. - ### Changed - [BUG-18099] Serviced the .NET 10 baseline to SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft packages, with CI auditing real self-contained publishes and release artifacts for every supported runtime identifier. - [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. @@ -15,7 +13,6 @@ - [VS-1880] Unified Windows Robocopy exclusions with the shared preset resolver. - [BUG-18103] Modernized Snapshot Explorer, metadata-import review, and updater windows around the current compact, theme-aware app layout. - [BUG-18104] Reworked development presets to preserve Git control files and shareable IDE configuration while excluding live Git internals and modern build, package, test, framework, and machine-local caches. - ### Fixed - [BUG-18098] Rebuilt roadmap description synchronization around tested wrapped-title parsing, ownership-aware body preservation, repository-contained inputs, validated GitHub identifiers, and an exact write-free dry-run report. - [BUG-18100] Restored `Dev` as the permanent integration branch at the `1.8.6` Stable commit and disabled automatic head-branch deletion so Stable promotion cannot remove it again. diff --git a/ROADMAP.md b/ROADMAP.md index 6a721b44..87829297 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -377,7 +377,7 @@ History carry the same folder identity. **Status:** Active development. Release contracts were approved and implementation started on 2026-08-12. **Tagline:** *Show the proof.* -**Target:** 2026-10-30 +**Target:** 2026-08-24 **Working branch:** `release/1.8.7` **Integration target:** `Dev` @@ -385,6 +385,12 @@ The maintained implementation status and safety contracts for this release live in `docs/RELEASE_1.8.7.md`. That page distinguishes shipped behavior from work that exists only on the release branch or remains planned. +Minor releases target a weekly train and must not remain open longer than two +weeks after the preceding Stable release. Release-blocking safety and regression +work stays in the active train; incomplete non-blocking polish moves forward to +the next minor rather than silently extending the release. Major releases begin +after the planned minor train is complete and use explicit beta qualification. + - [ ] `VS-1871` `P1` Expose build, channel, commit, runtime, architecture, package, and update-source information. - Scope: define one build-information contract used by the desktop About and diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 3f6b0ea5..4fc4c8f6 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -11,7 +11,7 @@ release. The canonical feature scope and acceptance criteria remain in | Current stable | `1.8.6` (`v1.8.6`, released 2026-08-10) | | Active target | `1.8.7` | | Planning started | 2026-08-12 | -| Stable target | 2026-10-30 | +| Stable target | 2026-08-24 | | Working branch | `release/1.8.7` | | Integration branch | `Dev` | | Stable branch | `Stable` | @@ -22,6 +22,24 @@ The release branch accumulates the qualified 1.8.7 work. `Dev` is the integration branch; `Stable` represents shipped releases only. A beta is not assumed and must be approved explicitly if the release needs one. +## Delivery timeline + +`1.8.6` shipped on 2026-08-10. The two-week minor-release ceiling therefore +sets 2026-08-24 as the Stable deadline for `1.8.7`. + +| Window | Focus | +|---|---| +| 16–19 August | Complete versioned, durable cross-machine conflict handling. | +| 20–21 August | Finish build identity, SBOM/provenance, evidence, and support-export contracts. | +| 22 August | Finish repository documentation, public metadata synchronization, and bounded cleanup. | +| 23 August | Run the unpublished stable candidate, upgrade, two-machine, NAS/SMB, localization, theme, accessibility, and security gates. | +| 24 August | Merge through `Dev` to `Stable`, publish, verify assets, and close the milestone. | + +P0 safety defects cannot roll forward. Non-blocking P1 polish may move to +`1.8.8` instead of extending this deadline. Minor releases do not require a +beta; major releases use explicit beta rounds once their feature train is +complete and stable enough for broader qualification. + ## Status as of 2026-08-16 ### Implemented on the release branch diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0940cb97..c70017fd 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,19 @@ This document defines the current release packaging flow. +## Release cadence + +- Minor releases target seven days and have a fourteen-day maximum from the + preceding Stable release. +- P0 safety or data-integrity work blocks the active minor. Unfinished + non-blocking work moves to the next minor instead of extending the train. +- Minor releases do not require a beta. An unpublished stable candidate still + runs the complete release matrix before promotion. +- Major releases begin after their planned minor train is complete and use one + or more explicit betas when the combined feature set is stable enough for + broader qualification. +- `1.8.7` follows this policy with a Stable deadline of 2026-08-24. + ## Prerequisites - .NET 10 SDK - Inno Setup (Windows installer) From 173c4e9e8e240a5be0bc6097a62ad9fe58f6e461 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:28:12 +0200 Subject: [PATCH 47/54] Make cross-machine conflict decisions durable --- CHANGELOG.md | 1 + Localization/strings.ar.json | 5 +- Localization/strings.bn.json | 5 +- Localization/strings.de.json | 5 +- Localization/strings.en.json | 7 +- Localization/strings.es.json | 5 +- Localization/strings.fr.json | 5 +- Localization/strings.hi.json | 5 +- Localization/strings.id.json | 5 +- Localization/strings.it.json | 5 +- Localization/strings.ja.json | 5 +- Localization/strings.ko.json | 5 +- Localization/strings.nl.json | 5 +- Localization/strings.pl.json | 5 +- Localization/strings.pt.json | 5 +- Localization/strings.ru.json | 5 +- Localization/strings.tr.json | 5 +- Localization/strings.uk.json | 5 +- Localization/strings.vi.json | 5 +- Localization/strings.zh.json | 5 +- ROADMAP.md | 5 +- docs/CROSS_MACHINE_SAFETY.md | 6 +- docs/REPOSITORY_FORMATS.md | 34 +-- docs/wiki/Metadata-Sync.md | 27 +- src/VaultSync.Core/Config/AppConfig.cs | 15 + src/VaultSync.Core/Services/MetadataStore.cs | 47 +++- .../Services/MetadataSyncService.cs | 266 ++++++++++++------ .../ViewModels/AppViewModel.RuntimeOps.cs | 4 +- .../ViewModels/MetadataSyncReviewViewModel.cs | 4 +- .../ViewModels/SettingsViewModel.cs | 105 ++++++- .../Views/MetadataSyncReviewWindow.axaml | 19 +- src/VaultSync.UI/Views/SettingsView.axaml | 54 +++- .../VaultSync.Core.Tests/MetadataSyncTests.cs | 144 +++++++++- 33 files changed, 636 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4efab70..72137108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - [BUG-18107] Stopped metadata import from exporting deletion tombstones for snapshots that were preserved because they still have local backups or never existed locally. - [BUG-18108] Stopped repeated background downloads of immutable release and platform patch manifests by persisting digest-verified cache entries across application restarts. - [BUG-18109] Bounded disposable logs, diagnostics, caches, patch runtimes, downloads, and temporary work, and stopped backups from writing into unmounted macOS managed-mount directories on the local system drive. +- [BUG-18101] Made cross-machine project-setting conflicts complete and durable: encryption keys and unmatched destinations stay local, avatar/encryption/auto-backup changes join the review, rejected revisions remain resolved, project writers are recorded per row, and automatic imports cannot apply destructive tombstones without review. ## [1.8.6] - 10.08.2026 ### Added diff --git a/Localization/strings.ar.json b/Localization/strings.ar.json index 1eb393d5..7d6422af 100644 --- a/Localization/strings.ar.json +++ b/Localization/strings.ar.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "إلغاء", "MetadataSync.Review.Confirm": "استيراد", "MetadataSync.Review.DeleteBackups": "النسخ الاحتياطية المراد حذفها", + "MetadataSync.Review.DeleteProjects": "المشاريع المراد حذفها", + "MetadataSync.Review.DeleteSnapshots": "اللقطات المراد حذفها", "MetadataSync.Review.LinkProjects": "مشاريع للربط", "MetadataSync.Review.SourceDestination": "الوجهة: {0}", "MetadataSync.Review.SourceLabel": "مصدر", "MetadataSync.Review.SourceProjectsRoot": "جذر المشاريع", "MetadataSync.Review.StoreLabel": "مخزن البيانات الوصفية", "MetadataSync.Review.Title": "مراجعة استيراد البيانات الوصفية", - "MetadataSync.Review.WarningDeletes": "سيؤدي هذا إلى حذف النسخ الاحتياطية {0} من السجل المحلي.", + "MetadataSync.Review.WarningDeletes": "سيؤدي هذا إلى حذف {0} عناصر من السجل المحلي.", "Nav.Backups": "النسخ الاحتياطية", "Nav.Dashboard": "لوحة التحكم", "Nav.History": "السجل", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "تحديث سجل البيانات الوصفية", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "استيراد أحدث بيانات الوجهة أثناء الصيانة.", "Settings.Advanced.MetadataConflictsTitle": "تعارضات البيانات الوصفية بين الأجهزة", + "Settings.Advanced.MetadataConflictsAvatarColor": "لون الصورة الرمزية", "Settings.Advanced.MetadataConflictsDescription": "راجع إعدادات المشاريع المستوردة من جهاز آخر قبل أن تستبدل إعدادات الوجهة أو وضع الاستعادة أو التحقق أو العلامات المحلية.", "Settings.Advanced.MetadataConflictsNone": "لا توجد تعارضات بيانات وصفية بين الأجهزة حالياً.", "Settings.Advanced.MetadataConflictsPending": "{0} تعارض بيانات وصفية بين الأجهزة.", diff --git a/Localization/strings.bn.json b/Localization/strings.bn.json index 9acefff4..0883526a 100644 --- a/Localization/strings.bn.json +++ b/Localization/strings.bn.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "বাতিল", "MetadataSync.Review.Confirm": "ইমপোর্ট", "MetadataSync.Review.DeleteBackups": "মুছে যাবে এমন ব্যাকআপ", + "MetadataSync.Review.DeleteProjects": "মুছে যাবে এমন প্রকল্প", + "MetadataSync.Review.DeleteSnapshots": "মুছে যাবে এমন স্ন্যাপশট", "MetadataSync.Review.LinkProjects": "লিঙ্ক হবে এমন প্রকল্প", "MetadataSync.Review.SourceDestination": "গন্তব্য: {0}", "MetadataSync.Review.SourceLabel": "উৎস", "MetadataSync.Review.SourceProjectsRoot": "প্রকল্প রুট", "MetadataSync.Review.StoreLabel": "মেটাডাটা স্টোর", "MetadataSync.Review.Title": "মেটাডাটা ইমপোর্ট পর্যালোচনা", - "MetadataSync.Review.WarningDeletes": "এতে লোকাল ইতিহাস থেকে {0}টি ব্যাকআপ মুছে যাবে।", + "MetadataSync.Review.WarningDeletes": "এতে লোকাল ইতিহাস থেকে {0}টি আইটেম মুছে যাবে।", "Nav.Backups": "ব্যাকআপ", "Nav.Dashboard": "ড্যাশবোর্ড", "Nav.History": "ইতিহাস", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "মেটাডেটা ইতিহাস রিফ্রেশ করুন", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "রক্ষণাবেক্ষণের সময় সর্বশেষ গন্তব্য মেটাডেটা আমদানি করুন।", "Settings.Advanced.MetadataConflictsTitle": "ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব", + "Settings.Advanced.MetadataConflictsAvatarColor": "অবতার রং", "Settings.Advanced.MetadataConflictsDescription": "লোকাল গন্তব্য, রিস্টোর মোড, যাচাইকরণ বা ট্যাগ ওভাররাইট করার আগে অন্য মেশিন থেকে আমদানিকৃত প্রকল্প সেটিংস পর্যালোচনা করুন।", "Settings.Advanced.MetadataConflictsNone": "কোনো ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব নেই।", "Settings.Advanced.MetadataConflictsPending": "{0}টি ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব অপেক্ষমান।", diff --git a/Localization/strings.de.json b/Localization/strings.de.json index e9dc03da..511dc5dc 100644 --- a/Localization/strings.de.json +++ b/Localization/strings.de.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Abbrechen", "MetadataSync.Review.Confirm": "Importieren", "MetadataSync.Review.DeleteBackups": "Backups löschen", + "MetadataSync.Review.DeleteProjects": "Projekte löschen", + "MetadataSync.Review.DeleteSnapshots": "Snapshots löschen", "MetadataSync.Review.LinkProjects": "Zu verknüpfende Projekte", "MetadataSync.Review.SourceDestination": "Ziel: {0}", "MetadataSync.Review.SourceLabel": "Quelle", "MetadataSync.Review.SourceProjectsRoot": "Projektstamm", "MetadataSync.Review.StoreLabel": "Metadatenspeicher", "MetadataSync.Review.Title": "Metadatenimport prüfen", - "MetadataSync.Review.WarningDeletes": "Dadurch werden {0} Backups aus dem lokalen Verlauf gelöscht.", + "MetadataSync.Review.WarningDeletes": "Dadurch werden {0} Einträge aus dem lokalen Verlauf gelöscht.", "Nav.Backups": "Sicherungen", "Nav.Dashboard": "Übersicht", "Nav.History": "Verlauf", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Metadatenverlauf aktualisieren", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Beim Wartungslauf die neuesten Ziel-Metadaten importieren.", "Settings.Advanced.MetadataConflictsTitle": "Geräteübergreifende Metadatenkonflikte", + "Settings.Advanced.MetadataConflictsAvatarColor": "Avatarfarbe", "Settings.Advanced.MetadataConflictsDescription": "Prüfen Sie von einem anderen Gerät importierte Projekteinstellungen, bevor diese Ihr lokales Ziel, den Wiederherstellungsmodus, die Verifizierung oder Tags überschreiben.", "Settings.Advanced.MetadataConflictsNone": "Keine ausstehenden geräteübergreifenden Metadatenkonflikte.", "Settings.Advanced.MetadataConflictsPending": "{0} ausstehende geräteübergreifende Metadatenkonflikte.", diff --git a/Localization/strings.en.json b/Localization/strings.en.json index 664f6861..9eddeb77 100644 --- a/Localization/strings.en.json +++ b/Localization/strings.en.json @@ -569,13 +569,15 @@ "MetadataSync.Review.Cancel": "Cancel", "MetadataSync.Review.Confirm": "Import", "MetadataSync.Review.DeleteBackups": "Backups to delete", + "MetadataSync.Review.DeleteProjects": "Projects to delete", + "MetadataSync.Review.DeleteSnapshots": "Snapshots to delete", "MetadataSync.Review.LinkProjects": "Projects to link", "MetadataSync.Review.SourceDestination": "Destination: {0}", "MetadataSync.Review.SourceLabel": "Source", "MetadataSync.Review.SourceProjectsRoot": "Projects root", "MetadataSync.Review.StoreLabel": "Metadata store", "MetadataSync.Review.Title": "Review metadata import", - "MetadataSync.Review.WarningDeletes": "This will delete {0} backups from local history.", + "MetadataSync.Review.WarningDeletes": "This will delete {0} items from local history.", "Nav.Backups": "Backups", "Nav.Dashboard": "Dashboard", "Nav.History": "History", @@ -1042,7 +1044,8 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Refresh metadata history", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Import latest destination metadata during the maintenance run.", "Settings.Advanced.MetadataConflictsTitle": "Cross-machine metadata conflicts", - "Settings.Advanced.MetadataConflictsDescription": "Review project settings imported from another machine before they overwrite your local destination, restore mode, verification, or tags.", + "Settings.Advanced.MetadataConflictsAvatarColor": "Avatar color", + "Settings.Advanced.MetadataConflictsDescription": "Review portable project settings before they overwrite local choices. Encryption key references and destination paths always stay on this machine.", "Settings.Advanced.MetadataConflictsNone": "No pending cross-machine metadata conflicts.", "Settings.Advanced.MetadataConflictsPending": "{0} pending cross-machine metadata conflict(s).", "Settings.Advanced.MetadataConflictsSourceLabel": "Imported from", diff --git a/Localization/strings.es.json b/Localization/strings.es.json index 40bbdb9d..5128d999 100644 --- a/Localization/strings.es.json +++ b/Localization/strings.es.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Cancelar", "MetadataSync.Review.Confirm": "Importar", "MetadataSync.Review.DeleteBackups": "Copias de seguridad a eliminar", + "MetadataSync.Review.DeleteProjects": "Proyectos a eliminar", + "MetadataSync.Review.DeleteSnapshots": "Instantáneas a eliminar", "MetadataSync.Review.LinkProjects": "Proyectos a vincular", "MetadataSync.Review.SourceDestination": "Destino: {0}", "MetadataSync.Review.SourceLabel": "Origen", "MetadataSync.Review.SourceProjectsRoot": "Raíz de proyectos", "MetadataSync.Review.StoreLabel": "Almacén de metadatos", "MetadataSync.Review.Title": "Revisar importación de metadatos", - "MetadataSync.Review.WarningDeletes": "Esto eliminará {0} copias de seguridad del historial local.", + "MetadataSync.Review.WarningDeletes": "Esto eliminará {0} elementos del historial local.", "Nav.Backups": "Respaldos", "Nav.Dashboard": "Panel", "Nav.History": "Historial", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Actualizar historial de metadatos", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importa los metadatos más recientes del destino durante mantenimiento.", "Settings.Advanced.MetadataConflictsTitle": "Conflictos de metadatos entre equipos", + "Settings.Advanced.MetadataConflictsAvatarColor": "Color del avatar", "Settings.Advanced.MetadataConflictsDescription": "Revisa configuraciones importadas desde otro equipo antes de sobrescribir destino, modo de restauración o etiquetas.", "Settings.Advanced.MetadataConflictsNone": "No hay conflictos de metadatos entre equipos.", "Settings.Advanced.MetadataConflictsPending": "{0} conflictos de metadatos pendientes.", diff --git a/Localization/strings.fr.json b/Localization/strings.fr.json index ff864ba5..1b4e2283 100644 --- a/Localization/strings.fr.json +++ b/Localization/strings.fr.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Annuler", "MetadataSync.Review.Confirm": "Importer", "MetadataSync.Review.DeleteBackups": "Sauvegardes à supprimer", + "MetadataSync.Review.DeleteProjects": "Projets à supprimer", + "MetadataSync.Review.DeleteSnapshots": "Instantanés à supprimer", "MetadataSync.Review.LinkProjects": "Projets à lier", "MetadataSync.Review.SourceDestination": "Destination : {0}", "MetadataSync.Review.SourceLabel": "Source", "MetadataSync.Review.SourceProjectsRoot": "Racine des projets", "MetadataSync.Review.StoreLabel": "Magasin de métadonnées", "MetadataSync.Review.Title": "Revoir l'import des métadonnées", - "MetadataSync.Review.WarningDeletes": "Cela supprimera {0} sauvegardes de l'historique local.", + "MetadataSync.Review.WarningDeletes": "Cela supprimera {0} éléments de l'historique local.", "Nav.Backups": "Sauvegardes", "Nav.Dashboard": "Tableau de bord", "Nav.History": "Historique", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Actualiser les métadonnées", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importer les métadonnées récentes.", "Settings.Advanced.MetadataConflictsTitle": "Conflits de métadonnées", + "Settings.Advanced.MetadataConflictsAvatarColor": "Couleur de l’avatar", "Settings.Advanced.MetadataConflictsDescription": "Examiner les paramètres importés avant d’écraser les paramètres locaux.", "Settings.Advanced.MetadataConflictsNone": "Aucun conflit de métadonnées.", "Settings.Advanced.MetadataConflictsPending": "{0} conflit(s) de métadonnées.", diff --git a/Localization/strings.hi.json b/Localization/strings.hi.json index 5e910757..9dcd56c3 100644 --- a/Localization/strings.hi.json +++ b/Localization/strings.hi.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "रद्द करें", "MetadataSync.Review.Confirm": "आयात करें", "MetadataSync.Review.DeleteBackups": "हटाए जाने वाले बैकअप", + "MetadataSync.Review.DeleteProjects": "हटाए जाने वाले प्रोजेक्ट", + "MetadataSync.Review.DeleteSnapshots": "हटाए जाने वाले स्नैपशॉट", "MetadataSync.Review.LinkProjects": "लिंक किए जाने वाले प्रोजेक्ट", "MetadataSync.Review.SourceDestination": "गंतव्य: {0}", "MetadataSync.Review.SourceLabel": "स्रोत", "MetadataSync.Review.SourceProjectsRoot": "प्रोजेक्ट रूट", "MetadataSync.Review.StoreLabel": "मेटाडेटा स्टोर", "MetadataSync.Review.Title": "मेटाडेटा आयात समीक्षा", - "MetadataSync.Review.WarningDeletes": "इससे स्थानीय इतिहास से {0} बैकअप हट जाएंगे।", + "MetadataSync.Review.WarningDeletes": "इससे स्थानीय इतिहास से {0} आइटम हट जाएंगे।", "Nav.Backups": "बैकअप", "Nav.Dashboard": "डैशबोर्ड", "Nav.History": "इतिहास", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "मेटाडेटा रीफ्रेश", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "नवीनतम मेटाडेटा आयात करें।", "Settings.Advanced.MetadataConflictsTitle": "मेटाडेटा संघर्ष", + "Settings.Advanced.MetadataConflictsAvatarColor": "अवतार रंग", "Settings.Advanced.MetadataConflictsDescription": "स्थानीय सेटिंग्स ओवरराइट करने से पहले आयातित सेटिंग्स की समीक्षा करें।", "Settings.Advanced.MetadataConflictsNone": "कोई मेटाडेटा संघर्ष नहीं।", "Settings.Advanced.MetadataConflictsPending": "{0} मेटाडेटा संघर्ष।", diff --git a/Localization/strings.id.json b/Localization/strings.id.json index c9ec3489..3d4a7cd9 100644 --- a/Localization/strings.id.json +++ b/Localization/strings.id.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Batalkan", "MetadataSync.Review.Confirm": "Impor", "MetadataSync.Review.DeleteBackups": "Cadangan untuk dihapus", + "MetadataSync.Review.DeleteProjects": "Proyek untuk dihapus", + "MetadataSync.Review.DeleteSnapshots": "Snapshot untuk dihapus", "MetadataSync.Review.LinkProjects": "Proyek untuk ditautkan", "MetadataSync.Review.SourceDestination": "Tujuan: {0}", "MetadataSync.Review.SourceLabel": "Sumber", "MetadataSync.Review.SourceProjectsRoot": "Akar proyek", "MetadataSync.Review.StoreLabel": "Penyimpanan metadata", "MetadataSync.Review.Title": "Tinjau impor metadata", - "MetadataSync.Review.WarningDeletes": "Ini akan menghapus cadangan {0} dari riwayat lokal.", + "MetadataSync.Review.WarningDeletes": "Ini akan menghapus {0} item dari riwayat lokal.", "Nav.Backups": "Cadangan", "Nav.Dashboard": "Dasbor", "Nav.History": "Sejarah", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Refresh riwayat metadata", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Impor metadata tujuan terbaru selama eksekusi pemeliharaan.", "Settings.Advanced.MetadataConflictsTitle": "Konflik metadata lintas mesin", + "Settings.Advanced.MetadataConflictsAvatarColor": "Warna avatar", "Settings.Advanced.MetadataConflictsDescription": "Tinjau pengaturan proyek yang diimpor dari komputer lain sebelum menimpa tujuan lokal, mode pemulihan, verifikasi, atau tag Anda.", "Settings.Advanced.MetadataConflictsNone": "Tidak ada konflik metadata lintas mesin yang tertunda.", "Settings.Advanced.MetadataConflictsPending": "{0} konflik metadata lintas mesin yang tertunda.", diff --git a/Localization/strings.it.json b/Localization/strings.it.json index c8655299..a4d47a65 100644 --- a/Localization/strings.it.json +++ b/Localization/strings.it.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Annulla", "MetadataSync.Review.Confirm": "Importa", "MetadataSync.Review.DeleteBackups": "Backup da eliminare", + "MetadataSync.Review.DeleteProjects": "Progetti da eliminare", + "MetadataSync.Review.DeleteSnapshots": "Snapshot da eliminare", "MetadataSync.Review.LinkProjects": "Progetti da collegare", "MetadataSync.Review.SourceDestination": "Destinazione: {0}", "MetadataSync.Review.SourceLabel": "Origine", "MetadataSync.Review.SourceProjectsRoot": "Radice progetti", "MetadataSync.Review.StoreLabel": "Archivio metadati", "MetadataSync.Review.Title": "Rivedi importazione metadati", - "MetadataSync.Review.WarningDeletes": "Questo eliminerà {0} backup dalla cronologia locale.", + "MetadataSync.Review.WarningDeletes": "Questo eliminerà {0} elementi dalla cronologia locale.", "Nav.Backups": "Backup", "Nav.Dashboard": "Panoramica", "Nav.History": "Cronologia", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Aggiorna metadati", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importa metadati più recenti.", "Settings.Advanced.MetadataConflictsTitle": "Conflitti metadati", + "Settings.Advanced.MetadataConflictsAvatarColor": "Colore avatar", "Settings.Advanced.MetadataConflictsDescription": "Controlla le impostazioni importate prima di sovrascrivere quelle locali.", "Settings.Advanced.MetadataConflictsNone": "Nessun conflitto metadati.", "Settings.Advanced.MetadataConflictsPending": "{0} conflitti metadati.", diff --git a/Localization/strings.ja.json b/Localization/strings.ja.json index ee5d5b31..7aa834ca 100644 --- a/Localization/strings.ja.json +++ b/Localization/strings.ja.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "キャンセル", "MetadataSync.Review.Confirm": "輸入", "MetadataSync.Review.DeleteBackups": "削除すべきバックアップ", + "MetadataSync.Review.DeleteProjects": "削除するプロジェクト", + "MetadataSync.Review.DeleteSnapshots": "削除するスナップショット", "MetadataSync.Review.LinkProjects": "リンク先のプロジェクト", "MetadataSync.Review.SourceDestination": "目的地:{0}", "MetadataSync.Review.SourceLabel": "出典", "MetadataSync.Review.SourceProjectsRoot": "プロジェクトの根源", "MetadataSync.Review.StoreLabel": "メタデータストア", "MetadataSync.Review.Title": "Review Metadata import", - "MetadataSync.Review.WarningDeletes": "これにより、ローカル履歴から{0}バックアップが削除されます。", + "MetadataSync.Review.WarningDeletes": "ローカル履歴から {0} 件の項目が削除されます。", "Nav.Backups": "バックアップ", "Nav.Dashboard": "ダッシュボード", "Nav.History": "歴史", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "メタデータ履歴の更新", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "メンテナンス中に最新の目的地メタデータをインポートしてください。", "Settings.Advanced.MetadataConflictsTitle": "クロスマシン間のメタデータ競合", + "Settings.Advanced.MetadataConflictsAvatarColor": "アバターの色", "Settings.Advanced.MetadataConflictsDescription": "他のマシンからインポートしたプロジェクト設定を、ローカルの目的地や復元モード、検証、タグが上書きされる前に必ず確認してください。", "Settings.Advanced.MetadataConflictsNone": "未処理のクロスマシンメタデータ競合はありません。", "Settings.Advanced.MetadataConflictsPending": "未処理中のクロスマシンメタデータの競合{0}。", diff --git a/Localization/strings.ko.json b/Localization/strings.ko.json index 1dcab7bf..02f377c4 100644 --- a/Localization/strings.ko.json +++ b/Localization/strings.ko.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "취소", "MetadataSync.Review.Confirm": "수입", "MetadataSync.Review.DeleteBackups": "삭제할 백업", + "MetadataSync.Review.DeleteProjects": "삭제할 프로젝트", + "MetadataSync.Review.DeleteSnapshots": "삭제할 스냅샷", "MetadataSync.Review.LinkProjects": "연결 프로젝트", "MetadataSync.Review.SourceDestination": "목적지: {0}", "MetadataSync.Review.SourceLabel": "출처", "MetadataSync.Review.SourceProjectsRoot": "프로젝트 뿌리", "MetadataSync.Review.StoreLabel": "메타데이터 저장소", "MetadataSync.Review.Title": "Review 메타데이터 가져오기", - "MetadataSync.Review.WarningDeletes": "이렇게 하면 로컬 기록에서 {0} 백업이 삭제됩니다.", + "MetadataSync.Review.WarningDeletes": "로컬 기록에서 {0}개 항목이 삭제됩니다.", "Nav.Backups": "백업", "Nav.Dashboard": "대시보드", "Nav.History": "역사", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "메타데이터 기록 새로고침", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "유지보수 실행 중에 최신 목적지 메타데이터를 가져오세요.", "Settings.Advanced.MetadataConflictsTitle": "크로스 머신 메타데이터 충돌", + "Settings.Advanced.MetadataConflictsAvatarColor": "아바타 색상", "Settings.Advanced.MetadataConflictsDescription": "다른 컴퓨터에서 가져온 프로젝트 설정이 로컬 목적지, 복원 모드, 검증, 태그를 덮어쓰기 전에 꼭 검토하세요.", "Settings.Advanced.MetadataConflictsNone": "대기 중인 크로스 머신 메타데이터 충돌도 없습니다.", "Settings.Advanced.MetadataConflictsPending": "{0} 대기 중인 크로스 머신 메타데이터 충돌.", diff --git a/Localization/strings.nl.json b/Localization/strings.nl.json index 893e5e7c..cf9c8eb3 100644 --- a/Localization/strings.nl.json +++ b/Localization/strings.nl.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Annuleren", "MetadataSync.Review.Confirm": "Importeren", "MetadataSync.Review.DeleteBackups": "Back-ups om te verwijderen", + "MetadataSync.Review.DeleteProjects": "Projecten om te verwijderen", + "MetadataSync.Review.DeleteSnapshots": "Momentopnamen om te verwijderen", "MetadataSync.Review.LinkProjects": "Projecten om te koppelen", "MetadataSync.Review.SourceDestination": "Bestemming: {0}", "MetadataSync.Review.SourceLabel": "Bron", "MetadataSync.Review.SourceProjectsRoot": "Projectwortel", "MetadataSync.Review.StoreLabel": "Metadata-opslag", "MetadataSync.Review.Title": "Bekijk metadata-import", - "MetadataSync.Review.WarningDeletes": "Dit verwijdert {0} back-ups uit de lokale geschiedenis.", + "MetadataSync.Review.WarningDeletes": "Dit verwijdert {0} items uit de lokale geschiedenis.", "Nav.Backups": "Reserves", "Nav.Dashboard": "Dashboard", "Nav.History": "Geschiedenis", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Ververs de metadatageschiedenis", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importeer de nieuwste bestemmingsmetadata tijdens de onderhoudsrun.", "Settings.Advanced.MetadataConflictsTitle": "Cross-machine metadataconflicten", + "Settings.Advanced.MetadataConflictsAvatarColor": "Avatarkleur", "Settings.Advanced.MetadataConflictsDescription": "Bekijk projectinstellingen die van een andere machine zijn geïmporteerd voordat ze je lokale bestemming, herstelmodus, verificatie of tags overschrijven.", "Settings.Advanced.MetadataConflictsNone": "Geen lopende cross-machine metadataconflicten.", "Settings.Advanced.MetadataConflictsPending": "{0} lopende cross-machine metadataconflict(en).", diff --git a/Localization/strings.pl.json b/Localization/strings.pl.json index 998eadd4..a9ade7b7 100644 --- a/Localization/strings.pl.json +++ b/Localization/strings.pl.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Anuluj", "MetadataSync.Review.Confirm": "Importuj", "MetadataSync.Review.DeleteBackups": "Kopie zapasowe do usunięcia", + "MetadataSync.Review.DeleteProjects": "Projekty do usunięcia", + "MetadataSync.Review.DeleteSnapshots": "Migawki do usunięcia", "MetadataSync.Review.LinkProjects": "Projekty do linkowania", "MetadataSync.Review.SourceDestination": "Cel podróży: {0}", "MetadataSync.Review.SourceLabel": "Źródło", "MetadataSync.Review.SourceProjectsRoot": "Korzenie projektów", "MetadataSync.Review.StoreLabel": "Magazyn metadanych", "MetadataSync.Review.Title": "Przegląd importu metadanych", - "MetadataSync.Review.WarningDeletes": "To usunie {0} kopii zapasowych z lokalnej historii.", + "MetadataSync.Review.WarningDeletes": "To usunie {0} elementów z lokalnej historii.", "Nav.Backups": "Kopie zapasowe", "Nav.Dashboard": "Dashboard", "Nav.History": "Historia", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Historia odświeżania metadanych", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importuj najnowsze metadane docelowe podczas przeglądu konserwacyjnego.", "Settings.Advanced.MetadataConflictsTitle": "Konflikty metadanych międzykomputerowych", + "Settings.Advanced.MetadataConflictsAvatarColor": "Kolor awatara", "Settings.Advanced.MetadataConflictsDescription": "Przejrzyj ustawienia projektu zaimportowane z innego komputera, zanim nadpiszą lokalny cel, tryb przywracania, weryfikację lub tagi.", "Settings.Advanced.MetadataConflictsNone": "Brak oczekujących konfliktów metadanych między maszynami.", "Settings.Advanced.MetadataConflictsPending": "{0} oczekujących konfliktów metadanych między maszynami.", diff --git a/Localization/strings.pt.json b/Localization/strings.pt.json index 3b779586..1f1828ab 100644 --- a/Localization/strings.pt.json +++ b/Localization/strings.pt.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Cancelar", "MetadataSync.Review.Confirm": "Importar", "MetadataSync.Review.DeleteBackups": "Backups a excluir", + "MetadataSync.Review.DeleteProjects": "Projetos a excluir", + "MetadataSync.Review.DeleteSnapshots": "Snapshots a excluir", "MetadataSync.Review.LinkProjects": "Projetos a vincular", "MetadataSync.Review.SourceDestination": "Destino: {0}", "MetadataSync.Review.SourceLabel": "Origem", "MetadataSync.Review.SourceProjectsRoot": "Raiz de projetos", "MetadataSync.Review.StoreLabel": "Armazenamento de metadados", "MetadataSync.Review.Title": "Revisar importação de metadados", - "MetadataSync.Review.WarningDeletes": "Isso excluirá {0} backups do histórico local.", + "MetadataSync.Review.WarningDeletes": "Isso excluirá {0} itens do histórico local.", "Nav.Backups": "Backups", "Nav.Dashboard": "Painel", "Nav.History": "Histórico", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Atualizar metadados", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Importar metadados recentes.", "Settings.Advanced.MetadataConflictsTitle": "Conflitos de metadados", + "Settings.Advanced.MetadataConflictsAvatarColor": "Cor do avatar", "Settings.Advanced.MetadataConflictsDescription": "Revise as configurações importadas antes de substituir as locais.", "Settings.Advanced.MetadataConflictsNone": "Nenhum conflito de metadados.", "Settings.Advanced.MetadataConflictsPending": "{0} conflitos de metadados.", diff --git a/Localization/strings.ru.json b/Localization/strings.ru.json index 541f2e51..edcf5629 100644 --- a/Localization/strings.ru.json +++ b/Localization/strings.ru.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Отмена", "MetadataSync.Review.Confirm": "Импортировать", "MetadataSync.Review.DeleteBackups": "Резервные копии для удаления", + "MetadataSync.Review.DeleteProjects": "Проекты для удаления", + "MetadataSync.Review.DeleteSnapshots": "Снимки для удаления", "MetadataSync.Review.LinkProjects": "Проекты для связывания", "MetadataSync.Review.SourceDestination": "Назначение: {0}", "MetadataSync.Review.SourceLabel": "Источник", "MetadataSync.Review.SourceProjectsRoot": "Корневая папка проектов", "MetadataSync.Review.StoreLabel": "Хранилище метаданных", "MetadataSync.Review.Title": "Просмотр импорта метаданных", - "MetadataSync.Review.WarningDeletes": "Будет удалено {0} резервных копий из локальной истории.", + "MetadataSync.Review.WarningDeletes": "Из локальной истории будет удалено {0} элементов.", "Nav.Backups": "Резервные копии", "Nav.Dashboard": "Панель управления", "Nav.History": "История", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Обновить метаданные", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Импортировать последние метаданные.", "Settings.Advanced.MetadataConflictsTitle": "Конфликты метаданных", + "Settings.Advanced.MetadataConflictsAvatarColor": "Цвет аватара", "Settings.Advanced.MetadataConflictsDescription": "Проверьте импортированные настройки перед перезаписью локальных.", "Settings.Advanced.MetadataConflictsNone": "Нет конфликтов метаданных.", "Settings.Advanced.MetadataConflictsPending": "{0} конфликтов метаданных.", diff --git a/Localization/strings.tr.json b/Localization/strings.tr.json index 8a8c2061..031f7fb2 100644 --- a/Localization/strings.tr.json +++ b/Localization/strings.tr.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "İptal et", "MetadataSync.Review.Confirm": "İthalat", "MetadataSync.Review.DeleteBackups": "Silmek için yedekler", + "MetadataSync.Review.DeleteProjects": "Silinecek projeler", + "MetadataSync.Review.DeleteSnapshots": "Silinecek anlık görüntüler", "MetadataSync.Review.LinkProjects": "Bağlantı Projeleri", "MetadataSync.Review.SourceDestination": "Varış noktası: {0}", "MetadataSync.Review.SourceLabel": "Kaynak", "MetadataSync.Review.SourceProjectsRoot": "Projelerin kökü", "MetadataSync.Review.StoreLabel": "Meta veri deposu", "MetadataSync.Review.Title": "Meta veri içe aktarımı incelemesi", - "MetadataSync.Review.WarningDeletes": "Bu, yerel geçmişten {0} yedeklemeleri silecektir.", + "MetadataSync.Review.WarningDeletes": "Bu, yerel geçmişten {0} öğe silecektir.", "Nav.Backups": "Yedekler", "Nav.Dashboard": "Kontrol paneli", "Nav.History": "Tarihçe", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Metadata geçmişini yenile", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Bakım çalışması sırasında en son hedef meta verileri içe aktarın.", "Settings.Advanced.MetadataConflictsTitle": "Makineler arası meta veri çatışmaları", + "Settings.Advanced.MetadataConflictsAvatarColor": "Avatar rengi", "Settings.Advanced.MetadataConflictsDescription": "Başka bir makineden alınan proje ayarlarını yerel hedefinizi, geri yükleme modunuzu, doğrulama veya etiketlerinizi üzerine yazmadan önce gözden geçirin.", "Settings.Advanced.MetadataConflictsNone": "Bekleyen makineler arası meta veri çatışması yok.", "Settings.Advanced.MetadataConflictsPending": "{0} bekleyen makineler arası meta veri çatışma(lar)ı.", diff --git a/Localization/strings.uk.json b/Localization/strings.uk.json index 6f105590..2154f8c5 100644 --- a/Localization/strings.uk.json +++ b/Localization/strings.uk.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Скасувати", "MetadataSync.Review.Confirm": "Імпорт", "MetadataSync.Review.DeleteBackups": "Резервні копії для видалення", + "MetadataSync.Review.DeleteProjects": "Проєкти для видалення", + "MetadataSync.Review.DeleteSnapshots": "Знімки для видалення", "MetadataSync.Review.LinkProjects": "Проєкти для посилання", "MetadataSync.Review.SourceDestination": "Пункт призначення: {0}", "MetadataSync.Review.SourceLabel": "Джерело", "MetadataSync.Review.SourceProjectsRoot": "Корені проєкту", "MetadataSync.Review.StoreLabel": "Сховище метаданих", "MetadataSync.Review.Title": "Імпорт метаданих перегляду", - "MetadataSync.Review.WarningDeletes": "Це видалить {0} резервні копії з місцевої історії.", + "MetadataSync.Review.WarningDeletes": "Це видалить {0} елементів із локальної історії.", "Nav.Backups": "Резервні копії", "Nav.Dashboard": "Панель керування", "Nav.History": "Історія", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Оновити історію метаданих", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Імпортуйте останні метадані призначення під час технічного обслуговування.", "Settings.Advanced.MetadataConflictsTitle": "Міжмашинні конфлікти метаданих", + "Settings.Advanced.MetadataConflictsAvatarColor": "Колір аватара", "Settings.Advanced.MetadataConflictsDescription": "Перегляньте налаштування проєкту, імпортовані з іншої машини, перш ніж вони перезапишуть ваш локальний пункт призначення, режим відновлення, верифікацію або теги.", "Settings.Advanced.MetadataConflictsNone": "Немає очікуваних конфліктів між метаданими між машинами.", "Settings.Advanced.MetadataConflictsPending": "{0} очікуваний конфлікт метаданих між крос-машинами.", diff --git a/Localization/strings.vi.json b/Localization/strings.vi.json index 7bf09327..414ec68c 100644 --- a/Localization/strings.vi.json +++ b/Localization/strings.vi.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "Hủy bỏ", "MetadataSync.Review.Confirm": "Nhập khẩu", "MetadataSync.Review.DeleteBackups": "Sao lưu để xóa", + "MetadataSync.Review.DeleteProjects": "Dự án cần xóa", + "MetadataSync.Review.DeleteSnapshots": "Ảnh chụp nhanh cần xóa", "MetadataSync.Review.LinkProjects": "Các dự án để liên kết", "MetadataSync.Review.SourceDestination": "Điểm đến: {0}", "MetadataSync.Review.SourceLabel": "Nguồn", "MetadataSync.Review.SourceProjectsRoot": "Gốc dự án", "MetadataSync.Review.StoreLabel": "Kho siêu dữ liệu", "MetadataSync.Review.Title": "Xem lại quá trình nhập siêu dữ liệu", - "MetadataSync.Review.WarningDeletes": "Thao tác này sẽ xóa các bản sao lưu {0} khỏi lịch sử cục bộ.", + "MetadataSync.Review.WarningDeletes": "Thao tác này sẽ xóa {0} mục khỏi lịch sử cục bộ.", "Nav.Backups": "Sao lưu", "Nav.Dashboard": "Bảng điều khiển", "Nav.History": "Lịch sử", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Làm mới lịch sử siêu dữ liệu", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Nhập siêu dữ liệu đích mới nhất trong quá trình bảo trì.", "Settings.Advanced.MetadataConflictsTitle": "Xung đột siêu dữ liệu giữa các máy", + "Settings.Advanced.MetadataConflictsAvatarColor": "Màu ảnh đại diện", "Settings.Advanced.MetadataConflictsDescription": "Xem lại cài đặt dự án được nhập từ một máy khác trước khi chúng ghi đè lên đích địa phương, chế độ khôi phục, xác minh hoặc thẻ của bạn.", "Settings.Advanced.MetadataConflictsNone": "Không có xung đột siêu dữ liệu giữa các máy đang chờ xử lý.", "Settings.Advanced.MetadataConflictsPending": "{0} xung đột siêu dữ liệu giữa các máy đang chờ xử lý.", diff --git a/Localization/strings.zh.json b/Localization/strings.zh.json index 0e8a28d9..6d9459e5 100644 --- a/Localization/strings.zh.json +++ b/Localization/strings.zh.json @@ -593,13 +593,15 @@ "MetadataSync.Review.Cancel": "取消", "MetadataSync.Review.Confirm": "导入", "MetadataSync.Review.DeleteBackups": "要删除的备份", + "MetadataSync.Review.DeleteProjects": "要删除的项目", + "MetadataSync.Review.DeleteSnapshots": "要删除的快照", "MetadataSync.Review.LinkProjects": "要关联的项目", "MetadataSync.Review.SourceDestination": "目标:{0}", "MetadataSync.Review.SourceLabel": "来源", "MetadataSync.Review.SourceProjectsRoot": "项目根目录", "MetadataSync.Review.StoreLabel": "元数据存储", "MetadataSync.Review.Title": "查看元数据导入", - "MetadataSync.Review.WarningDeletes": "将从本地历史记录中删除 {0} 个备份。", + "MetadataSync.Review.WarningDeletes": "将从本地历史记录中删除 {0} 个项目。", "Nav.Backups": "备份", "Nav.Dashboard": "仪表板", "Nav.History": "历史记录", @@ -1044,6 +1046,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "刷新元数据", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "导入最新元数据。", "Settings.Advanced.MetadataConflictsTitle": "元数据冲突", + "Settings.Advanced.MetadataConflictsAvatarColor": "头像颜色", "Settings.Advanced.MetadataConflictsDescription": "在覆盖本地设置前检查导入设置。", "Settings.Advanced.MetadataConflictsNone": "没有元数据冲突。", "Settings.Advanced.MetadataConflictsPending": "{0} 个元数据冲突。", diff --git a/ROADMAP.md b/ROADMAP.md index 87829297..755f14b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -501,7 +501,7 @@ after the planned minor train is complete and use explicit beta qualification. promotion merges from automatically deleting it. - Completed: `Dev` was restored at the exact `v1.8.6` Stable commit on 2026-08-12 and automatic head-branch deletion was disabled. -- [ ] `BUG-18101` `P0` Stop cross-machine metadata import from applying +- [x] `BUG-18101` `P0` Stop cross-machine metadata import from applying unreviewed settings or repeatedly resurfacing a rejected remote edit. - Scope: encryption policy and key references, auto-backup state, avatar color, tombstones, and all existing conflict fields must follow an explicit @@ -509,6 +509,9 @@ after the planned minor train is complete and use explicit beta qualification. - Acceptance: imports do not silently apply machine-local key references or destructive tombstones, every changed portable field appears in preview, writer attribution is record-specific, and resolved conflicts stay resolved. + - Completed on the 1.8.7 release branch on 2026-08-16 with version-2 project + writer/revision records, durable conflict decisions, complete portable-field + review, local-only key and destination handling, and destructive-import gates. - [x] `BUG-18102` `P0` Prevent deferred metadata replay from overwriting a destination that changed while it was unavailable. - Acceptance: deferred stores are lease-protected, flush at most once into an diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index e4c87816..513480b9 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -147,11 +147,15 @@ revision. **Implemented for the existing version-1 writers on 2026-08-12; every future migration or repair writer must enter through the same boundary.** 4. Add the versioned schema and forward migration fixtures. + **Version-2 project writer/revision columns and version-1 compatibility were + implemented on 2026-08-16; base revisions remain in the VS-1879 merge work.** 5. Produce merge plans without applying them. 6. Add explicit apply, durable resolution, and bounded undo. + **Durable Keep local and Accept imported decisions are implemented and + bounded; revision-aware undo remains in VS-1879.** 7. Expose status, takeover, and conflict review in the UI. **Writer status and explicit stale takeover were implemented on 2026-08-16; - versioned conflict review remains part of steps 4–6.** + complete portable-field conflict review was implemented on 2026-08-16.** 8. Qualify local disk, SMB/NAS, disconnection, skew, crash, and mixed-version scenarios before enabling multi-machine writes by default. diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index 7b93997d..4710482c 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -27,8 +27,8 @@ The SQLite metadata store uses schema version `1`. Its logical tables are: - `meta_info`: schema version, creation/write timestamps, writer app version, and the most recent store-level writer machine value; -- `projects`: external identity, name, preset, root-path hint, timestamps, and - JSON-encoded project settings; +- `projects`: external identity, name, preset, root-path hint, timestamps, + per-record writer/revision identity, and JSON-encoded project settings; - `snapshots`: external/project identities, creation time, counts, sizes, and diff summaries; - `backups`: external/project/snapshot identities, creation time, backup type @@ -37,12 +37,11 @@ The SQLite metadata store uses schema version `1`. Its logical tables are: - `tombstones`: entity type, external identity, deletion time, and origin machine value. -`settings_json` currently includes portable-looking values such as avatar color, -encryption policy and key reference, preferred destination, restore mode, -verification policy, auto-backup state, and tags. Not every one of these fields -is safe to apply automatically on another machine; the current limitation and -single-writer guidance are documented in -[Metadata Sync](wiki/Metadata-Sync.md#current-186-limitations). +`settings_json` includes avatar color, encryption policy, preferred destination, +restore mode, verification policy, auto-backup state, and tags. Encryption key +references are deliberately excluded because they identify credentials that +exist only on one installation. Imported destination choices resolve only to a +destination configured locally. ### Current compatibility behavior @@ -52,21 +51,24 @@ single-writer guidance are documented in not authoritative local paths. - Plaintext credentials and backup payload contents are not stored in the metadata database. -- The store-wide writer value identifies the latest store writer, not the author - of every individual field. +- Version-2 project rows record their writer and monotonically advancing + revision. Version-1 rows remain readable but have no trustworthy per-record + writer and therefore use conservative conflict review. - Process-local semaphores serialize one VaultSync process only and do not protect a repository from another machine. ## Repository coordination — Planned for 1.8.7 -The coordination database, durable installation identity, and protection of all -existing metadata writers are implemented on the 1.8.7 release branch. The later -portable-metadata schema revision will add, with explicit migrations and fixtures: +The coordination database, durable installation identity, protection of all +existing metadata writers, and version-2 per-project writer/revision fields are +implemented on the 1.8.7 release branch. The remaining three-way merge work will +add, with explicit migrations and fixtures: -- durable installation identity distinct from host name; -- per-record revision, base revision, writer identity, and timestamp; +- base revision and field-level provenance beyond the current project writer, + revision, and timestamp; - field-level portability and provenance for project settings; -- durable conflict-resolution records; +- repository-portable resolution and bounded undo records beyond the current + durable local resolution cache; - per-record linkage to the repository-scoped writer identity where required. The separate coordination database currently records one active lease with diff --git a/docs/wiki/Metadata-Sync.md b/docs/wiki/Metadata-Sync.md index 38d90a71..06265a0d 100644 --- a/docs/wiki/Metadata-Sync.md +++ b/docs/wiki/Metadata-Sync.md @@ -10,7 +10,7 @@ VaultSync can export a portable metadata store to backup destinations and later - Project identity: external id, name, preset, root path hint, timestamps - Portable project settings: - avatar color - - encryption policy and key reference + - encryption policy (never the machine-local key reference) - preferred destination id - restore mode - verification policy @@ -36,16 +36,22 @@ VaultSync can export a portable metadata store to backup destinations and later - Plaintext passwords or secret material - Full local app configuration - Full destination definitions from another machine +- Encryption key references or credential identifiers ## Preferred destination behavior - Imported `preferredDestinationId` values are normalized against your current configured destinations. - If the imported value matches a configured destination id, alias, or path, VaultSync resolves it to the local canonical destination id. -- If the imported value does not match a local destination, it may remain unresolved or be ignored depending on the import path. +- If the imported value does not match a local destination, VaultSync clears the unusable remote choice rather than retaining a foreign path or identifier. ## Conflict behavior - Some project settings do not silently overwrite differing local values on existing projects. -- In particular, preferred destination, restore mode, verification policy, and tags can create a metadata conflict record instead. +- Avatar color, encryption policy, preferred destination, restore mode, + verification policy, auto-backup state, and tags share one conflict record. - Review these conflicts from `Settings > Advanced > Doctor`. +- Keep local and Accept imported create bounded durable resolution records, so + an unchanged rejected revision does not reappear after restart. +- Automatic imports never apply project, snapshot, backup, or inferred deletion + changes. Manual refresh lists each destructive category and requires review. ### Current 1.8.6 limitations @@ -56,16 +62,11 @@ synchronized multi-writer configuration database. store. It does not retain a common base revision, so it cannot prove which of two independent edits is newer or automatically perform a true three-way merge. -- The store-level writer machine is not record-level provenance. A conflict can - therefore identify the most recent store writer rather than the machine that - originally changed that specific project field. -- `Keep local` dismisses the current conflict record but does not publish a - durable resolution to the destination. The same unchanged remote value can be - discovered again by a later import. -- Encryption policy and key-reference metadata, auto-backup state, avatar color, - and tombstones do not all use the same review path as the four visible - conflict fields. Treat cross-machine imports as a review operation and avoid - editing the same project from multiple machines concurrently. +- Version-2 project records carry a per-record writer and revision. Legacy + version-1 records remain readable but cannot provide trustworthy record-level + provenance and are handled conservatively. +- Project fields now share one review path and durable decisions, while true + three-way merge still requires the planned common base revision. - The in-process metadata gate coordinates one running VaultSync process only. It is not a cross-machine writer lock. diff --git a/src/VaultSync.Core/Config/AppConfig.cs b/src/VaultSync.Core/Config/AppConfig.cs index 2b075fe9..bfb675f0 100644 --- a/src/VaultSync.Core/Config/AppConfig.cs +++ b/src/VaultSync.Core/Config/AppConfig.cs @@ -346,6 +346,7 @@ public sealed class AdvancedConfig public bool HasSeenOnboarding { get; set; } = false; public BackupIndexScanSummary BackupIndexLastScan { get; set; } = new(); public List ProjectMetadataConflicts { get; set; } = []; + public List ProjectMetadataResolutions { get; set; } = []; public UpdateCheckDiagnostics UpdateDiagnostics { get; set; } = new(); public BackupRepairTelemetry BackupRepairTelemetry { get; set; } = new(); public MetadataConflictTelemetry MetadataConflictTelemetry { get; set; } = new(); @@ -391,12 +392,26 @@ public sealed class ProjectMetadataConflictRecord public sealed class ProjectMetadataConflictValues { + public string AvatarColor { get; set; } = string.Empty; + public string EncryptionPolicy { get; set; } = string.Empty; public string PreferredDestinationId { get; set; } = string.Empty; public string RestoreMode { get; set; } = string.Empty; public string VerificationPolicy { get; set; } = string.Empty; + public bool? AutoBackupEnabled { get; set; } public string Tags { get; set; } = string.Empty; } + public sealed class ProjectMetadataResolutionRecord + { + public string ProjectExternalId { get; set; } = string.Empty; + public string SourceMachineId { get; set; } = string.Empty; + public string SourceUpdatedUtc { get; set; } = string.Empty; + public string Decision { get; set; } = string.Empty; + public string ResolvedUtc { get; set; } = string.Empty; + public ProjectMetadataConflictValues Local { get; set; } = new(); + public ProjectMetadataConflictValues Imported { get; set; } = new(); + } + public sealed class UpdateCheckDiagnostics { public string CheckedUtc { get; set; } = string.Empty; diff --git a/src/VaultSync.Core/Services/MetadataStore.cs b/src/VaultSync.Core/Services/MetadataStore.cs index 0f334b22..bb17ccda 100644 --- a/src/VaultSync.Core/Services/MetadataStore.cs +++ b/src/VaultSync.Core/Services/MetadataStore.cs @@ -12,8 +12,9 @@ namespace VaultSync.Core.Services; public sealed class MetadataStore { - public const int CurrentSchemaVersion = 1; + public const int CurrentSchemaVersion = 2; private const string BackupsTable = "backups"; + private const string ProjectsTable = "projects"; private const string SnapshotsTable = "snapshots"; private readonly string _dbPath; @@ -71,7 +72,9 @@ CREATE TABLE IF NOT EXISTS projects( root_path_hint TEXT NOT NULL, created_utc TEXT NOT NULL, settings_json TEXT NOT NULL, - updated_utc TEXT NOT NULL + updated_utc TEXT NOT NULL, + writer_machine_id TEXT NOT NULL DEFAULT '', + revision INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS snapshots( @@ -121,6 +124,8 @@ PRIMARY KEY(entity_type, entity_id) EnsureColumn(c, BackupsTable, "enc_flag", "ALTER TABLE backups ADD COLUMN enc_flag INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(c, BackupsTable, "kdf_params_json", "ALTER TABLE backups ADD COLUMN kdf_params_json TEXT NOT NULL DEFAULT '{}';"); EnsureColumn(c, BackupsTable, "backup_mode", "ALTER TABLE backups ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'full';"); + EnsureColumn(c, ProjectsTable, "writer_machine_id", "ALTER TABLE projects ADD COLUMN writer_machine_id TEXT NOT NULL DEFAULT '';"); + EnsureColumn(c, ProjectsTable, "revision", "ALTER TABLE projects ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;"); EnsureColumn(c, SnapshotsTable, "diff_added", "ALTER TABLE snapshots ADD COLUMN diff_added INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(c, SnapshotsTable, "diff_modified", "ALTER TABLE snapshots ADD COLUMN diff_modified INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(c, SnapshotsTable, "diff_deleted", "ALTER TABLE snapshots ADD COLUMN diff_deleted INTEGER NOT NULL DEFAULT 0;"); @@ -195,14 +200,19 @@ public void UpsertProject(MetaProject project) { ExecuteWrite( """ - INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc) - VALUES(@ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc) + INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision) + VALUES(@ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @Revision) ON CONFLICT(external_id) DO UPDATE SET name = excluded.name, preset = excluded.preset, root_path_hint = excluded.root_path_hint, settings_json = excluded.settings_json, - updated_utc = excluded.updated_utc; + updated_utc = excluded.updated_utc, + writer_machine_id = excluded.writer_machine_id, + revision = CASE + WHEN excluded.revision > projects.revision THEN excluded.revision + ELSE projects.revision + 1 + END; """, new { @@ -212,7 +222,9 @@ ON CONFLICT(external_id) DO UPDATE SET project.RootPathHint, CreatedUtc = ToUtcString(project.CreatedUtc), project.SettingsJson, - UpdatedUtc = ToUtcString(project.UpdatedUtc) + UpdatedUtc = ToUtcString(project.UpdatedUtc), + project.WriterMachineId, + Revision = Math.Max(1, project.Revision) }); } @@ -341,9 +353,17 @@ private void ExecuteWrite(string sql, object? param = null) public IEnumerable ListProjects() { using SqliteConnection? c = TryOpenRead(); - return SafeQuery( - c, - """ + if (c is null) + return Array.Empty(); + + HashSet columns = GetTableColumns(c, ProjectsTable); + string writerProjection = columns.Contains("writer_machine_id") + ? "writer_machine_id as WriterMachineId" + : "'' as WriterMachineId"; + string revisionProjection = columns.Contains("revision") + ? "revision as Revision" + : "0 as Revision"; + string sql = $""" SELECT external_id as ExternalId, name, @@ -351,9 +371,12 @@ public IEnumerable ListProjects() root_path_hint as RootPathHint, created_utc as CreatedUtc, settings_json as SettingsJson, - updated_utc as UpdatedUtc + updated_utc as UpdatedUtc, + {writerProjection}, + {revisionProjection} FROM projects; - """); + """; + return SafeQuery(c, sql); } public IEnumerable ListProjectRefs() @@ -797,6 +820,8 @@ public sealed class MetaProject public DateTime CreatedUtc { get; set; } public string SettingsJson { get; set; } = string.Empty; public DateTime UpdatedUtc { get; set; } + public string WriterMachineId { get; set; } = string.Empty; + public long Revision { get; set; } } public sealed class MetaSnapshot diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 8dc9dd54..420a2c5f 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -74,6 +74,8 @@ private sealed record PreviewProjectCounts(int Add, int Link); private sealed record PreviewTombstoneAnalysis( HashSet BackupIds, HashSet SnapshotIds, + int DeleteProjects, + int DeleteSnapshots, int DeleteBackups); private sealed record PreviewBackupAnalysis(HashSet LiveSnapshotIds, int Add, int Delete); @@ -389,6 +391,9 @@ private MetadataSyncResult ImportFromStoreInternal( foreach (string? tombstonedProjectId in tombstonedProjectIds) { + if (!opts.ApplyDestructiveTombstones) + continue; + if (!projectMap.TryGetValue(tombstonedProjectId, out int existingId)) continue; @@ -410,7 +415,6 @@ private MetadataSyncResult ImportFromStoreInternal( continue; ParsedProjectSettings parsedSettings = ParseProjectSettings(metaProject.SettingsJson); - TryApplyProjectColor(metaProject); if (projectMap.TryGetValue(metaProject.ExternalId, out int mappedProjectId)) { @@ -418,7 +422,7 @@ private MetadataSyncResult ImportFromStoreInternal( mappedProjectId, config, metaProject, - metaInfo?.WriterMachineId, + ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); continue; @@ -451,7 +455,7 @@ private MetadataSyncResult ImportFromStoreInternal( existingByName.Id, config, metaProject, - metaInfo?.WriterMachineId, + ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); projectMap[metaProject.ExternalId] = existingByName.Id; @@ -478,14 +482,15 @@ private MetadataSyncResult ImportFromStoreInternal( EncryptionPolicy = parsedSettings.HasEncryptionPolicy ? parsedSettings.EncryptionPolicy : ProjectEncryptionPolicy.Inherit, - EncryptionKeyRef = parsedSettings.HasEncryptionKeyRef - ? parsedSettings.EncryptionKeyRef - : null, + // Encryption key references are installation-local secrets. A + // repository may describe the policy, but never selects a key + // that may not exist on this machine. + EncryptionKeyRef = null, VerificationPolicy = parsedSettings.HasVerificationPolicy ? parsedSettings.VerificationPolicy : ProjectVerificationPolicy.Always, PreferredDestinationId = parsedSettings.HasPreferredDestinationId - ? parsedSettings.PreferredDestinationId + ? NormalizeImportedPreferredDestinationId(parsedSettings.PreferredDestinationId, config.Backups.Destinations) : string.Empty, RestoreMode = parsedSettings.HasRestoreMode ? parsedSettings.RestoreMode @@ -496,6 +501,8 @@ private MetadataSyncResult ImportFromStoreInternal( }; int newId = _repo.AddProject(project); + if (parsedSettings.HasAvatarColor) + TryApplyProjectColor(metaProject.ExternalId, parsedSettings.AvatarColor); if (parsedSettings.HasAutoBackupEnabled) metadataConflictChanged |= ApplyImportedProjectAutoBackupSetting(config, newId, parsedSettings.AutoBackupEnabled); projectMap[metaProject.ExternalId] = newId; @@ -617,20 +624,23 @@ private MetadataSyncResult ImportFromStoreInternal( } } - foreach (MetaTombstone tombstone in metaTombstones) + if (opts.ApplyDestructiveTombstones) { - if (string.IsNullOrWhiteSpace(tombstone.EntityId)) - continue; - - if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase) && - backupExternalMap.TryGetValue(tombstone.EntityId, out int existingId)) + foreach (MetaTombstone tombstone in metaTombstones) { - _repo.DeleteBackupById(existingId); - appliedTombstones++; + if (string.IsNullOrWhiteSpace(tombstone.EntityId)) + continue; + + if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase) && + backupExternalMap.TryGetValue(tombstone.EntityId, out int existingId)) + { + _repo.DeleteBackupById(existingId); + appliedTombstones++; + } } } - if (missingBackupExternalIds.Count > 0) + if (missingBackupExternalIds.Count > 0 && opts.ApplyDestructiveTombstones) { foreach (string missingExternalId in missingBackupExternalIds) { @@ -647,7 +657,7 @@ private MetadataSyncResult ImportFromStoreInternal( } } - if (missingSnapshotExternalIds.Count > 0) + if (missingSnapshotExternalIds.Count > 0 && opts.ApplyDestructiveTombstones) { var removedSnapshotExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string? missingExternalId in missingSnapshotExternalIds) @@ -1129,7 +1139,11 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta IReadOnlyDictionary snapshotExternalMap = _repo.GetSnapshotExternalIdMap(); IReadOnlyDictionary backupExternalMap = _repo.GetBackupExternalIdMap(); - PreviewTombstoneAnalysis tombstones = AnalyzePreviewTombstones(metaTombstones, backupExternalMap); + PreviewTombstoneAnalysis tombstones = AnalyzePreviewTombstones( + metaTombstones, + projectExternalMap, + snapshotExternalMap, + backupExternalMap); PreviewBackupAnalysis backups = AnalyzePreviewBackups( metaBackups, rootPath, @@ -1174,7 +1188,11 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta addSnapshots, addBackups, tombstones.DeleteBackups + backups.Delete, - string.Empty); + string.Empty) + { + DeletedProjects = tombstones.DeleteProjects, + DeletedSnapshots = tombstones.DeleteSnapshots + }; if (metaInfo != null) { @@ -1217,25 +1235,34 @@ private static PreviewProjectCounts CountPreviewProjects( private static PreviewTombstoneAnalysis AnalyzePreviewTombstones( IEnumerable tombstones, + IReadOnlyDictionary projectExternalMap, + IReadOnlyDictionary snapshotExternalMap, IReadOnlyDictionary backupExternalMap) { var backupIds = new HashSet(StringComparer.OrdinalIgnoreCase); var snapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); - int deletes = 0; + int deleteProjects = 0; + int deleteSnapshots = 0; + int deleteBackups = 0; foreach (MetaTombstone tombstone in tombstones.Where(tombstone => !string.IsNullOrWhiteSpace(tombstone.EntityId))) { - if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(tombstone.EntityType, "project", StringComparison.OrdinalIgnoreCase)) + { + deleteProjects += projectExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; + } + else if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) { backupIds.Add(tombstone.EntityId); - deletes += backupExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; + deleteBackups += backupExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; } else if (string.Equals(tombstone.EntityType, "snapshot", StringComparison.OrdinalIgnoreCase)) { snapshotIds.Add(tombstone.EntityId); + deleteSnapshots += snapshotExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; } } - return new PreviewTombstoneAnalysis(backupIds, snapshotIds, deletes); + return new PreviewTombstoneAnalysis(backupIds, snapshotIds, deleteProjects, deleteSnapshots, deleteBackups); } private static PreviewBackupAnalysis AnalyzePreviewBackups( @@ -2204,6 +2231,7 @@ private static MetaInfo BuildUpdatedTombstoneMetaInfo( }; } + metaInfo.SchemaVersion = MetadataStore.CurrentSchemaVersion; metaInfo.LastWriteUtc = now; metaInfo.WriterMachineId = machineId; if (updateExistingAppVersion) @@ -2416,7 +2444,8 @@ private MetadataSyncResult ExportProjectToStoreInternal( RootPathHint = project.RootPath, CreatedUtc = project.CreatedUtc, SettingsJson = BuildProjectSettingsJson(project), - UpdatedUtc = now + UpdatedUtc = now, + WriterMachineId = machineId }); }); } @@ -2670,7 +2699,8 @@ private BackupExportCounts WriteBackupExport( RootPathHint = entities.Project.RootPath, CreatedUtc = entities.Project.CreatedUtc, SettingsJson = BuildProjectSettingsJson(entities.Project), - UpdatedUtc = context.Now + UpdatedUtc = context.Now, + WriterMachineId = context.MachineId }); store.UpsertSnapshot(new MetaSnapshot { @@ -3170,7 +3200,8 @@ private static bool TryCopyStoreFiles(string fromRoot, string toRoot) RootPathHint = project.RootPath, CreatedUtc = project.CreatedUtc, SettingsJson = BuildProjectSettingsJson(project), - UpdatedUtc = now + UpdatedUtc = now, + WriterMachineId = machineId }); foreach (Snapshot? snap in snapshots) @@ -3325,9 +3356,6 @@ private string BuildProjectSettingsJson(Project project) } settings["encryptionPolicy"] = ProjectEncryptionPolicy.Normalize(project.EncryptionPolicy); - settings["encryptionKeyRef"] = string.IsNullOrWhiteSpace(project.EncryptionKeyRef) - ? null - : project.EncryptionKeyRef; settings["preferredDestinationId"] = string.IsNullOrWhiteSpace(project.PreferredDestinationId) ? null : project.PreferredDestinationId; @@ -3348,15 +3376,15 @@ private string BuildProjectSettingsJson(Project project) } private readonly record struct ParsedProjectSettings( + string AvatarColor, string EncryptionPolicy, - string? EncryptionKeyRef, string PreferredDestinationId, string RestoreMode, string VerificationPolicy, bool AutoBackupEnabled, string Tags, + bool HasAvatarColor, bool HasEncryptionPolicy, - bool HasEncryptionKeyRef, bool HasPreferredDestinationId, bool HasRestoreMode, bool HasVerificationPolicy, @@ -3371,32 +3399,31 @@ private ParsedProjectSettings ParseProjectSettings(string? settingsJson) try { using var doc = JsonDocument.Parse(settingsJson); + string avatarColor = string.Empty; string policy = ProjectEncryptionPolicy.Inherit; - string? keyRef = null; string preferredDestinationId = string.Empty; string restoreMode = ProjectRestoreMode.Direct; string verificationPolicy = ProjectVerificationPolicy.Always; bool autoBackupEnabled = true; string tags = string.Empty; bool hasPolicy = false; - bool hasKeyRef = false; bool hasPreferredDestinationId = false; bool hasRestoreMode = false; bool hasVerificationPolicy = false; bool hasAutoBackupEnabled = false; bool hasTags = false; + bool hasAvatarColor = false; - if (doc.RootElement.TryGetProperty("encryptionPolicy", out JsonElement policyProp)) + if (doc.RootElement.TryGetProperty("avatarColor", out JsonElement avatarColorProp)) { - policy = ProjectEncryptionPolicy.Normalize(policyProp.GetString()); - hasPolicy = true; + avatarColor = NormalizeAvatarColor(avatarColorProp.GetString()); + hasAvatarColor = !string.IsNullOrWhiteSpace(avatarColor); } - if (doc.RootElement.TryGetProperty("encryptionKeyRef", out JsonElement keyRefProp)) + if (doc.RootElement.TryGetProperty("encryptionPolicy", out JsonElement policyProp)) { - string? rawKeyRef = keyRefProp.GetString(); - keyRef = string.IsNullOrWhiteSpace(rawKeyRef) ? null : rawKeyRef; - hasKeyRef = true; + policy = ProjectEncryptionPolicy.Normalize(policyProp.GetString()); + hasPolicy = true; } if (doc.RootElement.TryGetProperty("verificationPolicy", out JsonElement verificationProp)) @@ -3438,15 +3465,15 @@ private ParsedProjectSettings ParseProjectSettings(string? settingsJson) } return new ParsedProjectSettings( + avatarColor, policy, - keyRef, preferredDestinationId, restoreMode, verificationPolicy, autoBackupEnabled, tags, + HasAvatarColor: hasAvatarColor, HasEncryptionPolicy: hasPolicy, - HasEncryptionKeyRef: hasKeyRef, HasPreferredDestinationId: hasPreferredDestinationId, HasRestoreMode: hasRestoreMode, HasVerificationPolicy: hasVerificationPolicy, @@ -3461,15 +3488,15 @@ private ParsedProjectSettings ParseProjectSettings(string? settingsJson) private static ParsedProjectSettings EmptyParsedProjectSettings() => new( + string.Empty, ProjectEncryptionPolicy.Inherit, - null, string.Empty, ProjectRestoreMode.Direct, ProjectVerificationPolicy.Always, true, string.Empty, + HasAvatarColor: false, HasEncryptionPolicy: false, - HasEncryptionKeyRef: false, HasPreferredDestinationId: false, HasRestoreMode: false, HasVerificationPolicy: false, @@ -3484,8 +3511,8 @@ private bool ApplyImportedProjectSettings( ParsedProjectSettings parsedSettings, IList pendingConflicts) { - if (!parsedSettings.HasEncryptionPolicy && - !parsedSettings.HasEncryptionKeyRef && + if (!parsedSettings.HasAvatarColor && + !parsedSettings.HasEncryptionPolicy && !parsedSettings.HasPreferredDestinationId && !parsedSettings.HasRestoreMode && !parsedSettings.HasVerificationPolicy && @@ -3499,6 +3526,10 @@ private bool ApplyImportedProjectSettings( if (current is null) return false; + string currentAvatarColor = NormalizeAvatarColor(_projectColorResolver?.Invoke(current)); + string nextAvatarColor = parsedSettings.HasAvatarColor + ? parsedSettings.AvatarColor + : currentAvatarColor; string currentPolicy = ProjectEncryptionPolicy.Normalize(current.EncryptionPolicy); string incomingPolicy = parsedSettings.HasEncryptionPolicy ? ProjectEncryptionPolicy.Normalize(parsedSettings.EncryptionPolicy) @@ -3511,9 +3542,6 @@ private bool ApplyImportedProjectSettings( && !string.Equals(currentPolicy, ProjectEncryptionPolicy.Inherit, StringComparison.OrdinalIgnoreCase)); string nextPolicy = applyPolicy ? incomingPolicy : currentPolicy; - string? nextKeyRef = parsedSettings.HasEncryptionKeyRef - ? parsedSettings.EncryptionKeyRef - : current.EncryptionKeyRef; string currentVerificationPolicy = ProjectVerificationPolicy.Normalize(current.VerificationPolicy); string nextVerificationPolicy = parsedSettings.HasVerificationPolicy ? ProjectVerificationPolicy.Normalize(parsedSettings.VerificationPolicy) @@ -3521,7 +3549,7 @@ private bool ApplyImportedProjectSettings( List destinations = _configStore.Load().Backups.Destinations; string currentPreferredDestinationId = NormalizePreferredDestinationId(current.PreferredDestinationId, destinations); string nextPreferredDestinationId = parsedSettings.HasPreferredDestinationId - ? NormalizePreferredDestinationId(parsedSettings.PreferredDestinationId, destinations) + ? NormalizeImportedPreferredDestinationId(parsedSettings.PreferredDestinationId, destinations) : currentPreferredDestinationId; string currentRestoreMode = ProjectRestoreMode.Normalize(current.RestoreMode); string nextRestoreMode = parsedSettings.HasRestoreMode @@ -3538,9 +3566,7 @@ private bool ApplyImportedProjectSettings( : currentTags; string? currentKeyRef = string.IsNullOrWhiteSpace(current.EncryptionKeyRef) ? null : current.EncryptionKeyRef; - string? normalizedNextKeyRef = string.IsNullOrWhiteSpace(nextKeyRef) ? null : nextKeyRef; if (string.Equals(nextPolicy, currentPolicy, StringComparison.OrdinalIgnoreCase) && - string.Equals(normalizedNextKeyRef, currentKeyRef, StringComparison.Ordinal) && string.Equals(nextVerificationPolicy, currentVerificationPolicy, StringComparison.OrdinalIgnoreCase) && string.Equals(nextPreferredDestinationId, currentPreferredDestinationId, StringComparison.OrdinalIgnoreCase) && string.Equals(nextRestoreMode, currentRestoreMode, StringComparison.OrdinalIgnoreCase) && @@ -3550,24 +3576,50 @@ private bool ApplyImportedProjectSettings( return RemoveProjectMetadataConflict(projectId, pendingConflicts); } - _repo.UpdateProjectEncryptionSettings(projectId, nextPolicy, normalizedNextKeyRef); - bool conflictValuesDiffer = + !string.Equals(nextAvatarColor, currentAvatarColor, StringComparison.OrdinalIgnoreCase) || + !string.Equals(nextPolicy, currentPolicy, StringComparison.OrdinalIgnoreCase) || !string.Equals(nextPreferredDestinationId, currentPreferredDestinationId, StringComparison.OrdinalIgnoreCase) || !string.Equals(nextRestoreMode, currentRestoreMode, StringComparison.OrdinalIgnoreCase) || !string.Equals(nextVerificationPolicy, currentVerificationPolicy, StringComparison.OrdinalIgnoreCase) || + nextAutoBackupEnabled != currentAutoBackupEnabled || !string.Equals(nextTags, currentTags, StringComparison.Ordinal); + var localValues = new ProjectMetadataConflictValues + { + AvatarColor = currentAvatarColor, + EncryptionPolicy = currentPolicy, + PreferredDestinationId = currentPreferredDestinationId, + RestoreMode = currentRestoreMode, + VerificationPolicy = currentVerificationPolicy, + AutoBackupEnabled = currentAutoBackupEnabled, + Tags = currentTags + }; + var incomingValues = new ProjectMetadataConflictValues + { + AvatarColor = nextAvatarColor, + EncryptionPolicy = nextPolicy, + PreferredDestinationId = nextPreferredDestinationId, + RestoreMode = nextRestoreMode, + VerificationPolicy = nextVerificationPolicy, + AutoBackupEnabled = nextAutoBackupEnabled, + Tags = nextTags + }; + + if (conflictValuesDiffer && HasDurableKeepLocalResolution(config, metaProject, sourceMachineId, incomingValues)) + return RemoveProjectMetadataConflict(projectId, pendingConflicts); + if (!conflictValuesDiffer) { + if (parsedSettings.HasAvatarColor) + TryApplyProjectColor(metaProject.ExternalId, nextAvatarColor); + _repo.UpdateProjectEncryptionSettings(projectId, nextPolicy, currentKeyRef); _repo.UpdateProjectPreferredDestination(projectId, nextPreferredDestinationId); _repo.UpdateProjectRestoreMode(projectId, nextRestoreMode); _repo.UpdateProjectVerificationPolicy(projectId, nextVerificationPolicy); _repo.UpdateProjectTags(projectId, nextTags); if (parsedSettings.HasAutoBackupEnabled) - { ApplyImportedProjectAutoBackupSetting(config, projectId, nextAutoBackupEnabled); - } return RemoveProjectMetadataConflict(projectId, pendingConflicts); } @@ -3576,23 +3628,31 @@ private bool ApplyImportedProjectSettings( current, metaProject, sourceMachineId, - new ProjectMetadataConflictValues - { - PreferredDestinationId = currentPreferredDestinationId, - RestoreMode = currentRestoreMode, - VerificationPolicy = currentVerificationPolicy, - Tags = currentTags - }, - new ProjectMetadataConflictValues - { - PreferredDestinationId = nextPreferredDestinationId, - RestoreMode = nextRestoreMode, - VerificationPolicy = nextVerificationPolicy, - Tags = nextTags - }), + localValues, + incomingValues), pendingConflicts); } + private static bool HasDurableKeepLocalResolution( + AppConfig config, + MetaProject project, + string? sourceMachineId, + ProjectMetadataConflictValues incomingValues) + { + string sourceUpdatedUtc = project.UpdatedUtc == default + ? string.Empty + : project.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + string normalizedSourceMachineId = string.IsNullOrWhiteSpace(sourceMachineId) + ? UnknownAppVersion + : sourceMachineId; + return (config.Advanced.ProjectMetadataResolutions ?? []).Any(resolution => + string.Equals(resolution.Decision, "keep-local", StringComparison.OrdinalIgnoreCase) && + string.Equals(resolution.ProjectExternalId, project.ExternalId, StringComparison.OrdinalIgnoreCase) && + string.Equals(resolution.SourceMachineId, normalizedSourceMachineId, StringComparison.Ordinal) && + string.Equals(resolution.SourceUpdatedUtc, sourceUpdatedUtc, StringComparison.Ordinal) && + ProjectMetadataConflictValuesEqual(resolution.Imported, incomingValues)); + } + private static bool ApplyImportedProjectAutoBackupSetting(AppConfig config, int projectId, bool enabled) { config.Backups.AutoBackupDisabledProjects ??= []; @@ -3677,50 +3737,82 @@ private static bool ProjectMetadataConflictEquals(ProjectMetadataConflictRecord private static bool ProjectMetadataConflictValuesEqual(ProjectMetadataConflictValues left, ProjectMetadataConflictValues right) { - return string.Equals(left.PreferredDestinationId, right.PreferredDestinationId, StringComparison.OrdinalIgnoreCase) && + return string.Equals(left.AvatarColor, right.AvatarColor, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.EncryptionPolicy, right.EncryptionPolicy, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.PreferredDestinationId, right.PreferredDestinationId, StringComparison.OrdinalIgnoreCase) && string.Equals(left.RestoreMode, right.RestoreMode, StringComparison.OrdinalIgnoreCase) && string.Equals(left.VerificationPolicy, right.VerificationPolicy, StringComparison.OrdinalIgnoreCase) && + left.AutoBackupEnabled == right.AutoBackupEnabled && string.Equals(left.Tags, right.Tags, StringComparison.Ordinal); } + private static string? ResolveProjectWriterMachineId(MetaProject project, MetaInfo? storeInfo) + => string.IsNullOrWhiteSpace(project.WriterMachineId) + ? storeInfo?.WriterMachineId + : project.WriterMachineId; + private static string NormalizePreferredDestinationId(string? preferredDestinationId, IReadOnlyCollection destinations) => DestinationIdentityService.NormalizePreferredDestinationId(preferredDestinationId, destinations); - private void TryApplyProjectColor(MetaProject metaProject) + private static string NormalizeImportedPreferredDestinationId( + string? preferredDestinationId, + IReadOnlyCollection destinations) { - if (_projectColorApplier is null || string.IsNullOrWhiteSpace(metaProject.ExternalId)) + string normalized = DestinationIdentityService.NormalizePreferredDestinationId(preferredDestinationId, destinations); + if (string.IsNullOrWhiteSpace(normalized) || + string.Equals(normalized, Project.DestinationAllId, StringComparison.OrdinalIgnoreCase)) + { + return normalized; + } + + BackupDestination? localDestination = DestinationIdentityService.FindByPreferredDestinationId(destinations, normalized); + return localDestination is null ? string.Empty : DestinationIdentityService.GetId(localDestination); + } + + private void TryApplyProjectColor(string projectExternalId, string color) + { + if (_projectColorApplier is null || string.IsNullOrWhiteSpace(projectExternalId)) return; try { - if (string.IsNullOrWhiteSpace(metaProject.SettingsJson)) - return; - - using var doc = JsonDocument.Parse(metaProject.SettingsJson); - if (!doc.RootElement.TryGetProperty("avatarColor", out JsonElement colorProp)) - return; - - string? color = colorProp.GetString(); if (string.IsNullOrWhiteSpace(color)) return; - _projectColorApplier(metaProject.ExternalId, color); + _projectColorApplier(projectExternalId, color); } catch { // ignore malformed settings json } } + + private static string NormalizeAvatarColor(string? value) + { + string color = value?.Trim() ?? string.Empty; + if (color.Length != 7 || color[0] != '#') + return string.Empty; + + return color.AsSpan(1).IndexOfAnyExcept("0123456789abcdefABCDEF") >= 0 + ? string.Empty + : color.ToUpperInvariant(); + } } public sealed record MetadataSyncOptions( bool AllowCreateProjects, bool MarkNeedsRestoreOnImport, bool ExportMissingTombstonesOnImport = true, - bool SkipUnchangedReadOnlySource = false) + bool SkipUnchangedReadOnlySource = false, + bool ApplyDestructiveTombstones = true) { public static MetadataSyncOptions Default => new(true, true); - public MetadataSyncOptions AsReadOnlySource() => this with { ExportMissingTombstonesOnImport = false }; + public MetadataSyncOptions WithoutSourceWrites() => this with { ExportMissingTombstonesOnImport = false }; + public MetadataSyncOptions AsReadOnlySource() => this with + { + ExportMissingTombstonesOnImport = false, + ApplyDestructiveTombstones = false + }; public MetadataSyncOptions WithUnchangedSourceSkip() => this with { SkipUnchangedReadOnlySource = true }; } @@ -3753,12 +3845,16 @@ public sealed record MetadataSyncPreview( int DeletedBackups, string Message) { + public int DeletedProjects { get; init; } + public int DeletedSnapshots { get; init; } + public int TotalDeletes => DeletedProjects + DeletedSnapshots + DeletedBackups; + public bool HasChanges => NewProjects > 0 || LinkedProjects > 0 || NewSnapshots > 0 || NewBackups > 0 || - DeletedBackups > 0; + TotalDeletes > 0; public static MetadataSyncPreview Failure(MetadataSyncStatus status, string rootPath, string databasePath, string message) => new(status, rootPath, databasePath, 0, 0, 0, 0, 0, message); diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs index f6f2cf8f..0de05071 100644 --- a/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs +++ b/src/VaultSync.UI/ViewModels/AppViewModel.RuntimeOps.cs @@ -553,7 +553,7 @@ private async Task RefreshMetadataNowAsync() MetadataSyncOptions options = new MetadataSyncOptions( AllowCreateProjects: true, MarkNeedsRestoreOnImport: cfg.Backups.PromptRestoreAfterImport) - .AsReadOnlySource(); + .WithoutSourceWrites(); MetadataSyncPreview preview = await _metadataSyncService.PreviewImportFromStoreAsync(cfg.ProjectsRoot, options); string label = L("MetadataSync.Review.SourceProjectsRoot", "Projects root"); if (await ConfirmMetadataImportAsync(preview, label)) @@ -589,7 +589,7 @@ private async Task RefreshMetadataNowAsync() MetadataSyncOptions options = new MetadataSyncOptions( AllowCreateProjects: true, MarkNeedsRestoreOnImport: cfg.Backups.PromptRestoreAfterImport) - .AsReadOnlySource(); + .WithoutSourceWrites(); MetadataSyncPreview preview = await _metadataSyncService.PreviewImportFromStoreAsync(resolution.EffectivePath, options); string name = string.IsNullOrWhiteSpace(dest.Alias) ? dest.Path : dest.Alias!; string label = Lf("MetadataSync.Review.SourceDestination", "Destination: {0}", name); diff --git a/src/VaultSync.UI/ViewModels/MetadataSyncReviewViewModel.cs b/src/VaultSync.UI/ViewModels/MetadataSyncReviewViewModel.cs index 185baa84..c5a15050 100644 --- a/src/VaultSync.UI/ViewModels/MetadataSyncReviewViewModel.cs +++ b/src/VaultSync.UI/ViewModels/MetadataSyncReviewViewModel.cs @@ -32,7 +32,7 @@ public MetadataSyncReviewViewModel(LocalizationService localization, MetadataSyn public MetadataSyncPreview Preview { get; } public string SourceLabel { get; } - public bool HasDeletes => Preview.DeletedBackups > 0; + public bool HasDeletes => Preview.TotalDeletes > 0; public bool Confirmed => _confirmed; public ICommand ConfirmCommand { get; } @@ -43,5 +43,5 @@ public MetadataSyncReviewViewModel(LocalizationService localization, MetadataSyn public string WarningDeletesText => string.Format( _localization.GetString("MetadataSync.Review.WarningDeletes"), - Preview.DeletedBackups); + Preview.TotalDeletes); } diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs index 7dfd78bc..7d3c95dc 100644 --- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs +++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs @@ -235,12 +235,18 @@ public sealed class ProjectMetadataConflictItemViewModel public required string ProjectExternalId { get; init; } public required string SourceMachineId { get; init; } public required string SourceUpdatedUtc { get; init; } + public required string LocalAvatarColor { get; init; } + public required string ImportedAvatarColor { get; init; } + public required string LocalEncryptionPolicy { get; init; } + public required string ImportedEncryptionPolicy { get; init; } public required string LocalPreferredDestinationId { get; init; } public required string ImportedPreferredDestinationId { get; init; } public required string LocalRestoreMode { get; init; } public required string ImportedRestoreMode { get; init; } public required string LocalVerificationPolicy { get; init; } public required string ImportedVerificationPolicy { get; init; } + public required string LocalAutoBackupEnabled { get; init; } + public required string ImportedAutoBackupEnabled { get; init; } public required string LocalTags { get; init; } public required string ImportedTags { get; init; } } @@ -4103,12 +4109,18 @@ private void RefreshProjectMetadataConflicts(IEnumerable string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + private string FormatConflictBoolean(bool? value) + => value switch + { + true => L("Common.Yes", "Yes"), + false => L("Common.No", "No"), + null => "-" + }; + private static string FormatConflictUtc(string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -4159,11 +4179,28 @@ await Task.Run(() => { AppConfig cfg = _configStore.Load(); var repo = _repositoryFactory.Create(cfg); - repo.UpdateProjectPreferredDestination(item.ProjectId, EmptyToNull(item.ImportedPreferredDestinationId)); - repo.UpdateProjectRestoreMode(item.ProjectId, EmptyToNull(item.ImportedRestoreMode)); - repo.UpdateProjectVerificationPolicy(item.ProjectId, EmptyToNull(item.ImportedVerificationPolicy)); - repo.UpdateProjectTags(item.ProjectId, EmptyToNull(item.ImportedTags)); - + ProjectMetadataConflictRecord conflict = FindProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId) + ?? throw new InvalidOperationException("The metadata conflict is no longer pending."); + ProjectMetadataConflictValues imported = conflict.Imported ?? new ProjectMetadataConflictValues(); + Project? current = repo.GetProjectById(item.ProjectId); + if (current is null) + throw new InvalidOperationException("The project no longer exists."); + + repo.UpdateProjectEncryptionSettings( + item.ProjectId, + string.IsNullOrWhiteSpace(imported.EncryptionPolicy) + ? current.EncryptionPolicy + : imported.EncryptionPolicy, + current.EncryptionKeyRef); + repo.UpdateProjectPreferredDestination(item.ProjectId, EmptyToNull(imported.PreferredDestinationId)); + repo.UpdateProjectRestoreMode(item.ProjectId, EmptyToNull(imported.RestoreMode)); + repo.UpdateProjectVerificationPolicy(item.ProjectId, EmptyToNull(imported.VerificationPolicy)); + repo.UpdateProjectTags(item.ProjectId, EmptyToNull(imported.Tags)); + ApplyResolvedAutoBackupSetting(cfg, item.ProjectId, imported.AutoBackupEnabled); + if (!string.IsNullOrWhiteSpace(imported.AvatarColor)) + AvatarColorProvider.SetColorForExternalId(conflict.ProjectExternalId, imported.AvatarColor); + + RecordProjectMetadataResolution(cfg, conflict, "accept-imported"); RemoveProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId); UpdateMetadataConflictTelemetry(cfg, "accept-imported", item.ProjectName, Math.Max(0, cfg.Advanced.ProjectMetadataConflicts.Count)); _configStore.Save(cfg); @@ -4228,6 +4265,9 @@ private async Task KeepLocalProjectMetadataConflictAsync(ProjectMetadataConflict await Task.Run(() => { AppConfig cfg = _configStore.Load(); + ProjectMetadataConflictRecord conflict = FindProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId) + ?? throw new InvalidOperationException("The metadata conflict is no longer pending."); + RecordProjectMetadataResolution(cfg, conflict, "keep-local"); RemoveProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId); UpdateMetadataConflictTelemetry(cfg, "keep-local", item.ProjectName, Math.Max(0, cfg.Advanced.ProjectMetadataConflicts.Count)); _configStore.Save(cfg); @@ -4277,16 +4317,65 @@ await Dispatcher.UIThread.InvokeAsync(() => private static void RemoveProjectMetadataConflictRecord(AppConfig cfg, int projectId, string projectExternalId) { cfg.Advanced.ProjectMetadataConflicts ??= []; - ProjectMetadataConflictRecord? existing = cfg.Advanced.ProjectMetadataConflicts.FirstOrDefault(conflict => + ProjectMetadataConflictRecord? existing = FindProjectMetadataConflictRecord(cfg, projectId, projectExternalId); + if (existing is not null) + { + cfg.Advanced.ProjectMetadataConflicts.Remove(existing); + } + } + + private static ProjectMetadataConflictRecord? FindProjectMetadataConflictRecord( + AppConfig cfg, + int projectId, + string projectExternalId) + => (cfg.Advanced.ProjectMetadataConflicts ?? []).FirstOrDefault(conflict => conflict.ProjectId == projectId || (!string.IsNullOrWhiteSpace(projectExternalId) && string.Equals(conflict.ProjectExternalId, projectExternalId, StringComparison.OrdinalIgnoreCase))); - if (existing is not null) + + private static void RecordProjectMetadataResolution( + AppConfig cfg, + ProjectMetadataConflictRecord conflict, + string decision) + { + cfg.Advanced.ProjectMetadataResolutions ??= []; + cfg.Advanced.ProjectMetadataResolutions.RemoveAll(existing => + string.Equals(existing.ProjectExternalId, conflict.ProjectExternalId, StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.SourceMachineId, conflict.SourceMachineId, StringComparison.Ordinal) && + string.Equals(existing.SourceUpdatedUtc, conflict.SourceUpdatedUtc, StringComparison.Ordinal)); + cfg.Advanced.ProjectMetadataResolutions.Add(new ProjectMetadataResolutionRecord + { + ProjectExternalId = conflict.ProjectExternalId, + SourceMachineId = conflict.SourceMachineId, + SourceUpdatedUtc = conflict.SourceUpdatedUtc, + Decision = decision, + ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + Local = conflict.Local ?? new ProjectMetadataConflictValues(), + Imported = conflict.Imported ?? new ProjectMetadataConflictValues() + }); + + const int maxResolutionRecords = 100; + if (cfg.Advanced.ProjectMetadataResolutions.Count > maxResolutionRecords) { - cfg.Advanced.ProjectMetadataConflicts.Remove(existing); + cfg.Advanced.ProjectMetadataResolutions = cfg.Advanced.ProjectMetadataResolutions + .OrderByDescending(static record => record.ResolvedUtc, StringComparer.Ordinal) + .Take(maxResolutionRecords) + .ToList(); } } + private static void ApplyResolvedAutoBackupSetting(AppConfig cfg, int projectId, bool? enabled) + { + if (!enabled.HasValue) + return; + + cfg.Backups.AutoBackupDisabledProjects ??= []; + if (enabled.Value) + cfg.Backups.AutoBackupDisabledProjects.Remove(projectId); + else if (!cfg.Backups.AutoBackupDisabledProjects.Contains(projectId)) + cfg.Backups.AutoBackupDisabledProjects.Add(projectId); + } + private void PersistMetadataConflictTelemetry(string? lastAction, string? lastResolvedProject, int pendingCount) { try diff --git a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml index 8bf3fba2..1f73beca 100644 --- a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml +++ b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml @@ -98,12 +98,19 @@ - - - + + + + + + + + + + + + + diff --git a/src/VaultSync.UI/Views/SettingsView.axaml b/src/VaultSync.UI/Views/SettingsView.axaml index da62f2bd..b146f30b 100644 --- a/src/VaultSync.UI/Views/SettingsView.axaml +++ b/src/VaultSync.UI/Views/SettingsView.axaml @@ -2150,7 +2150,7 @@ + + + + + + + + - - - - - - - - - + + + + - - diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs index d63f2e65..26618226 100644 --- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs +++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs @@ -213,6 +213,20 @@ public void PreviewImportFromStore_CountsChangesWithoutMutatingTheRepository() DeletedUtc = DateTime.UtcNow, OriginMachineId = "remote-machine" }); + store.AddTombstone(new MetaTombstone + { + EntityType = "project", + EntityId = "local-project", + DeletedUtc = DateTime.UtcNow, + OriginMachineId = "remote-machine" + }); + store.AddTombstone(new MetaTombstone + { + EntityType = "snapshot", + EntityId = "local-snapshot", + DeletedUtc = DateTime.UtcNow, + OriginMachineId = "remote-machine" + }); var service = new MetadataSyncService(repo); MetadataSyncPreview preview = service.PreviewImportFromStore(metaRoot, MetadataSyncOptions.Default); @@ -223,6 +237,8 @@ public void PreviewImportFromStore_CountsChangesWithoutMutatingTheRepository() Assert.Equal(0, preview.LinkedProjects); Assert.Equal(1, preview.NewSnapshots); Assert.Equal(1, preview.NewBackups); + Assert.Equal(1, preview.DeletedProjects); + Assert.Equal(1, preview.DeletedSnapshots); Assert.Equal(2, preview.DeletedBackups); Assert.Equal(preview, cachedPreview); Assert.Null(repo.GetProjectByExternalId("remote-project")); @@ -1105,6 +1121,50 @@ public void ImportFromStore_ReadOnlySource_SkipsMissingBackupTombstoneWrite() Assert.DoesNotContain("backup-readonly-source-missing", backupTombstones, StringComparer.OrdinalIgnoreCase); } + [Fact] + public void ImportFromStore_ReadOnlySource_NeverAppliesDestructiveTombstones() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = TestRepository.AddProject(repo, "Read-only Tombstone", CreateTempDir(), "unity", DateTime.UtcNow); + int snapshotId = repo.CreateSnapshotFromMetadata( + "snapshot-readonly-tombstone", + projectId, + DateTime.UtcNow.AddHours(-1), + 1, + 64); + repo.CreateBackupFromMetadata( + "backup-readonly-tombstone", + projectId, + snapshotId, + DateTime.UtcNow, + "manual", + 64, + "readonly/tombstone", + metaRoot, + "Primary", + isProtected: false, + isImported: false); + + MetadataStore store = CreateStore(metaRoot); + SeedMetaInfo(store, "machine-remote"); + store.AddTombstone(new MetaTombstone + { + EntityType = "backup", + EntityId = "backup-readonly-tombstone", + DeletedUtc = DateTime.UtcNow, + OriginMachineId = "machine-remote" + }); + + var service = new MetadataSyncService(repo); + MetadataSyncResult result = service.ImportFromStore(metaRoot, MetadataSyncOptions.Default.AsReadOnlySource()); + + Assert.Equal(MetadataSyncStatus.Success, result.Status); + Assert.Equal(0, result.AppliedTombstones); + Assert.NotNull(repo.GetBackupByExternalId("backup-readonly-tombstone")); + } + [Fact] public void ImportFromStore_ActiveWriterAutomaticallyMakesSourceReadOnly() { @@ -1662,7 +1722,35 @@ public void MetadataStore_WriteBatch_RollsBackAllWritesOnFailure() } [Fact] - public void ImportFromStore_ProjectSettings_AppliesEncryptionPolicyAndKeyRef() + public void MetadataStore_ProjectRows_RecordWriterAndAdvanceRevision() + { + string metaRoot = CreateTempDir(); + MetadataStore store = CreateStore(metaRoot); + var project = new MetaProject + { + ExternalId = "project-versioned-writer", + Name = "Versioned Writer", + Preset = "generic", + RootPathHint = CreateTempDir(), + CreatedUtc = DateTime.UtcNow.AddDays(-1), + SettingsJson = "{}", + UpdatedUtc = DateTime.UtcNow, + WriterMachineId = "machine-a", + Revision = 1 + }; + + store.UpsertProject(project); + project.WriterMachineId = "machine-b"; + project.UpdatedUtc = project.UpdatedUtc.AddMinutes(1); + store.UpsertProject(project); + + MetaProject stored = Assert.Single(store.ListProjects()); + Assert.Equal("machine-b", stored.WriterMachineId); + Assert.Equal(2, stored.Revision); + } + + [Fact] + public void ImportFromStore_ProjectSettings_AppliesEncryptionPolicyButNeverImportsKeyRef() { string metaRoot = CreateTempDir(); string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); @@ -1688,7 +1776,7 @@ public void ImportFromStore_ProjectSettings_AppliesEncryptionPolicyAndKeyRef() Project project = repo.GetProjectByName("Project Settings"); Assert.NotNull(project); Assert.Equal(ProjectEncryptionPolicy.Encrypted, project!.EncryptionPolicy); - Assert.Equal("project-key-ref-01", project.EncryptionKeyRef); + Assert.Null(project.EncryptionKeyRef); } [Fact] @@ -1728,7 +1816,7 @@ public void ImportFromStore_ProjectSettings_AppliesAutoBackupPreference() } [Fact] - public void ImportFromStore_ProjectSettings_UpdatesExistingProjectKeyRef() + public void ImportFromStore_ProjectSettings_PreservesExistingProjectKeyRef() { string metaRoot = CreateTempDir(); string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); @@ -1766,11 +1854,11 @@ public void ImportFromStore_ProjectSettings_UpdatesExistingProjectKeyRef() Project project = repo.GetProjectById(projectId); Assert.NotNull(project); Assert.Equal(ProjectEncryptionPolicy.Encrypted, project!.EncryptionPolicy); - Assert.Equal("remote-key-ref-new", project.EncryptionKeyRef); + Assert.Equal("local-key-ref-old", project.EncryptionKeyRef); } [Fact] - public void ImportFromStore_ProjectSettings_CanClearExistingProjectKeyRef() + public void ImportFromStore_ProjectSettings_CannotClearExistingProjectKeyRef() { string metaRoot = CreateTempDir(); string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); @@ -1808,7 +1896,7 @@ public void ImportFromStore_ProjectSettings_CanClearExistingProjectKeyRef() Project project = repo.GetProjectById(projectId); Assert.NotNull(project); Assert.Equal(ProjectEncryptionPolicy.Encrypted, project!.EncryptionPolicy); - Assert.Null(project.EncryptionKeyRef); + Assert.Equal("local-key-ref", project.EncryptionKeyRef); } [Fact] @@ -1847,7 +1935,7 @@ public void ImportFromStore_ProjectSettings_RecordsConflictInsteadOfOverwritingT Preset = "unity", RootPathHint = projectRoot, CreatedUtc = now.AddDays(-2), - SettingsJson = "{\"preferredDestinationId\":\"dest-imported\",\"restoreMode\":\"sandbox\",\"verificationPolicy\":\"manual\",\"tags\":\"imported,remote\"}", + SettingsJson = "{\"encryptionPolicy\":\"plain\",\"preferredDestinationId\":\"dest-imported\",\"restoreMode\":\"sandbox\",\"verificationPolicy\":\"manual\",\"autoBackupEnabled\":false,\"tags\":\"imported,remote\"}", UpdatedUtc = now }); @@ -1862,19 +1950,45 @@ public void ImportFromStore_ProjectSettings_RecordsConflictInsteadOfOverwritingT Assert.Equal(ProjectRestoreMode.Direct, project.RestoreMode); Assert.Equal(ProjectVerificationPolicy.Always, project.VerificationPolicy); Assert.Equal("local,stable", project.Tags); + Assert.Equal(ProjectEncryptionPolicy.Inherit, project.EncryptionPolicy); + Assert.DoesNotContain(projectId, AppConfigStore.Load().Backups.AutoBackupDisabledProjects); AppConfig refreshedConfig = AppConfigStore.Load(); ProjectMetadataConflictRecord conflict = Assert.Single(refreshedConfig.Advanced.ProjectMetadataConflicts); Assert.Equal(projectId, conflict.ProjectId); Assert.Equal("machine-conflict", conflict.SourceMachineId); Assert.Equal("dest-local", conflict.Local.PreferredDestinationId); - Assert.Equal("dest-imported", conflict.Imported.PreferredDestinationId); + Assert.Equal(string.Empty, conflict.Imported.PreferredDestinationId); Assert.Equal(ProjectRestoreMode.Direct, conflict.Local.RestoreMode); Assert.Equal(ProjectRestoreMode.Sandbox, conflict.Imported.RestoreMode); + Assert.Equal(ProjectEncryptionPolicy.Inherit, conflict.Local.EncryptionPolicy); + Assert.Equal(ProjectEncryptionPolicy.Plain, conflict.Imported.EncryptionPolicy); + Assert.True(conflict.Local.AutoBackupEnabled); + Assert.False(conflict.Imported.AutoBackupEnabled); + + refreshedConfig.Advanced.ProjectMetadataConflicts.Clear(); + refreshedConfig.Advanced.ProjectMetadataResolutions = + [ + new ProjectMetadataResolutionRecord + { + ProjectExternalId = conflict.ProjectExternalId, + SourceMachineId = conflict.SourceMachineId, + SourceUpdatedUtc = conflict.SourceUpdatedUtc, + Decision = "keep-local", + ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + Local = conflict.Local, + Imported = conflict.Imported + } + ]; + AppConfigStore.Save(refreshedConfig); + + MetadataSyncResult repeated = service.ImportFromStore(metaRoot, MetadataSyncOptions.Default); + Assert.Equal(MetadataSyncStatus.Success, repeated.Status); + Assert.Empty(AppConfigStore.Load().Advanced.ProjectMetadataConflicts); } [Fact] - public void ExportBackupToStore_ProjectSettings_IncludeEncryptionFields() + public void ExportBackupToStore_ProjectSettings_ExcludesMachineLocalEncryptionKeyRef() { string metaRoot = CreateTempDir(); string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); @@ -1906,15 +2020,16 @@ public void ExportBackupToStore_ProjectSettings_IncludeEncryptionFields() var store = new MetadataStore(metaRoot); MetaProject metaProject = store.ListProjects().Single(); + Assert.Equal("machine-settings", metaProject.WriterMachineId); + Assert.True(metaProject.Revision >= 1); using var doc = JsonDocument.Parse(metaProject.SettingsJson); Assert.True(doc.RootElement.TryGetProperty("encryptionPolicy", out JsonElement policy)); Assert.Equal(ProjectEncryptionPolicy.Encrypted, policy.GetString()); - Assert.True(doc.RootElement.TryGetProperty("encryptionKeyRef", out JsonElement keyRef)); - Assert.Equal("project-key-ref-export", keyRef.GetString()); + Assert.False(doc.RootElement.TryGetProperty("encryptionKeyRef", out _)); } [Fact] - public void ExportBackupToStore_ProjectSettings_IncludeNullEncryptionKeyRefWhenUnset() + public void ExportBackupToStore_ProjectSettings_DoesNotEmitNullEncryptionKeyRef() { string metaRoot = CreateTempDir(); string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); @@ -1948,8 +2063,7 @@ public void ExportBackupToStore_ProjectSettings_IncludeNullEncryptionKeyRefWhenU using var doc = JsonDocument.Parse(metaProject.SettingsJson); Assert.True(doc.RootElement.TryGetProperty("encryptionPolicy", out JsonElement policy)); Assert.Equal(ProjectEncryptionPolicy.Encrypted, policy.GetString()); - Assert.True(doc.RootElement.TryGetProperty("encryptionKeyRef", out JsonElement keyRef)); - Assert.Equal(JsonValueKind.Null, keyRef.ValueKind); + Assert.False(doc.RootElement.TryGetProperty("encryptionKeyRef", out _)); } [Fact] @@ -2702,7 +2816,7 @@ PRIMARY KEY(entity_type, entity_id) INSERT INTO meta_info(schema_version, created_utc, last_write_utc, writer_app_version, writer_machine_id) VALUES($schemaVersion, $createdUtc, $lastWriteUtc, $appVersion, $machineId); """; - cmd.Parameters.AddWithValue("$schemaVersion", MetadataStore.CurrentSchemaVersion); + cmd.Parameters.AddWithValue("$schemaVersion", 1); cmd.Parameters.AddWithValue("$createdUtc", now); cmd.Parameters.AddWithValue("$lastWriteUtc", now); cmd.Parameters.AddWithValue("$appVersion", "1.4.0"); From b6dffd9119117ee627220b35c7db99b055dc593f Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:39:39 +0200 Subject: [PATCH 48/54] Merge cross-machine metadata changes safely --- src/VaultSync.Core/Config/AppConfig.cs | 22 +++ src/VaultSync.Core/Config/AppConfigStore.cs | 53 ++++++ .../Services/MetadataSyncService.cs | 162 +++++++++++++++--- .../Services/ProjectMetadataMergePlanner.cs | 102 +++++++++++ .../ViewModels/SettingsViewModel.cs | 43 ++++- .../VaultSync.Core.Tests/MetadataSyncTests.cs | 56 ++++++ .../ProjectMetadataMergePlannerTests.cs | 76 ++++++++ 7 files changed, 487 insertions(+), 27 deletions(-) create mode 100644 src/VaultSync.Core/Services/ProjectMetadataMergePlanner.cs create mode 100644 tests/VaultSync.Core.Tests/ProjectMetadataMergePlannerTests.cs diff --git a/src/VaultSync.Core/Config/AppConfig.cs b/src/VaultSync.Core/Config/AppConfig.cs index bfb675f0..4a06403d 100644 --- a/src/VaultSync.Core/Config/AppConfig.cs +++ b/src/VaultSync.Core/Config/AppConfig.cs @@ -347,6 +347,7 @@ public sealed class AdvancedConfig public BackupIndexScanSummary BackupIndexLastScan { get; set; } = new(); public List ProjectMetadataConflicts { get; set; } = []; public List ProjectMetadataResolutions { get; set; } = []; + public List ProjectMetadataMergeBases { get; set; } = []; public UpdateCheckDiagnostics UpdateDiagnostics { get; set; } = new(); public BackupRepairTelemetry BackupRepairTelemetry { get; set; } = new(); public MetadataConflictTelemetry MetadataConflictTelemetry { get; set; } = new(); @@ -386,8 +387,15 @@ public sealed class ProjectMetadataConflictRecord public string ProjectName { get; set; } = string.Empty; public string SourceMachineId { get; set; } = string.Empty; public string SourceUpdatedUtc { get; set; } = string.Empty; + public string SourceKey { get; set; } = string.Empty; + public long SourceRevision { get; set; } + public long BaseRevision { get; set; } + public List ConflictingFields { get; set; } = []; + public ProjectMetadataConflictValues Base { get; set; } = new(); public ProjectMetadataConflictValues Local { get; set; } = new(); public ProjectMetadataConflictValues Imported { get; set; } = new(); + public ProjectMetadataConflictValues KeepLocalResult { get; set; } = new(); + public ProjectMetadataConflictValues AcceptImportedResult { get; set; } = new(); } public sealed class ProjectMetadataConflictValues @@ -403,13 +411,27 @@ public sealed class ProjectMetadataConflictValues public sealed class ProjectMetadataResolutionRecord { + public string SourceKey { get; set; } = string.Empty; public string ProjectExternalId { get; set; } = string.Empty; public string SourceMachineId { get; set; } = string.Empty; public string SourceUpdatedUtc { get; set; } = string.Empty; + public long SourceRevision { get; set; } + public long BaseRevision { get; set; } public string Decision { get; set; } = string.Empty; public string ResolvedUtc { get; set; } = string.Empty; public ProjectMetadataConflictValues Local { get; set; } = new(); public ProjectMetadataConflictValues Imported { get; set; } = new(); + public ProjectMetadataConflictValues Result { get; set; } = new(); + } + + public sealed class ProjectMetadataMergeBaseRecord + { + public string SourceKey { get; set; } = string.Empty; + public string ProjectExternalId { get; set; } = string.Empty; + public long Revision { get; set; } + public string WriterMachineId { get; set; } = string.Empty; + public string UpdatedUtc { get; set; } = string.Empty; + public ProjectMetadataConflictValues Values { get; set; } = new(); } public sealed class UpdateCheckDiagnostics diff --git a/src/VaultSync.Core/Config/AppConfigStore.cs b/src/VaultSync.Core/Config/AppConfigStore.cs index 7926b2b3..fea1450e 100644 --- a/src/VaultSync.Core/Config/AppConfigStore.cs +++ b/src/VaultSync.Core/Config/AppConfigStore.cs @@ -340,6 +340,7 @@ private static void PreserveDurableConfigValues(AppConfig config) { PreserveProjectsRoot(config); PreserveMetadataImportCache(config); + PreserveMetadataMergeBases(config); } private static void PreserveProjectsRoot(AppConfig config) @@ -403,6 +404,58 @@ private static List CloneMetadataImportSources(IEnume .ToList(); } + private static void PreserveMetadataMergeBases(AppConfig config) + { + config.Advanced ??= new AdvancedConfig(); + config.Advanced.ProjectMetadataMergeBases ??= []; + AppConfig? persisted = TryLoadPersistedConfigForPreservation(ConfigFilePath) + ?? TryLoadPersistedConfigForPreservation(ConfigBackupFilePath) + ?? GetLastKnownGoodClone(); + List? persistedBases = persisted?.Advanced?.ProjectMetadataMergeBases; + if (persistedBases is not { Count: > 0 }) + return; + + int preserved = 0; + foreach (ProjectMetadataMergeBaseRecord item in persistedBases) + { + ProjectMetadataMergeBaseRecord? pending = config.Advanced.ProjectMetadataMergeBases.FirstOrDefault(candidate => + string.Equals(candidate.SourceKey, item.SourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.ProjectExternalId, item.ProjectExternalId, StringComparison.OrdinalIgnoreCase)); + if (pending is not null && (pending.Revision > item.Revision || + (pending.Revision == item.Revision && string.CompareOrdinal(pending.UpdatedUtc, item.UpdatedUtc) >= 0))) + { + continue; + } + + if (pending is not null) + config.Advanced.ProjectMetadataMergeBases.Remove(pending); + config.Advanced.ProjectMetadataMergeBases.Add(CloneMetadataMergeBase(item)); + preserved++; + } + + if (preserved > 0) + RuntimeLog.WriteVerbose($"[Config] Save preserved {preserved} newer metadata merge base(s) from durable config."); + } + + private static ProjectMetadataMergeBaseRecord CloneMetadataMergeBase(ProjectMetadataMergeBaseRecord item) => new() + { + SourceKey = item.SourceKey, + ProjectExternalId = item.ProjectExternalId, + Revision = item.Revision, + WriterMachineId = item.WriterMachineId, + UpdatedUtc = item.UpdatedUtc, + Values = new ProjectMetadataConflictValues + { + AvatarColor = item.Values.AvatarColor, + EncryptionPolicy = item.Values.EncryptionPolicy, + PreferredDestinationId = item.Values.PreferredDestinationId, + RestoreMode = item.Values.RestoreMode, + VerificationPolicy = item.Values.VerificationPolicy, + AutoBackupEnabled = item.Values.AutoBackupEnabled, + Tags = item.Values.Tags + } + }; + private static AppConfig? TryLoadPersistedConfigForPreservation(string path) { if (!File.Exists(path)) diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 420a2c5f..64c452c8 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -83,9 +83,13 @@ private sealed record PreviewBackupAnalysis(HashSet LiveSnapshotIds, int private sealed record ProjectMetadataConflictContext( Project Current, MetaProject Imported, + string SourceKey, string? SourceMachineId, + long BaseRevision, + ProjectMetadataConflictValues Base, ProjectMetadataConflictValues Local, - ProjectMetadataConflictValues Incoming); + ProjectMetadataConflictValues Incoming, + ProjectMetadataMergePlan Plan); private sealed class LegacyImportState { @@ -356,6 +360,8 @@ private MetadataSyncResult ImportFromStoreInternal( localProjects = _repo.GetAllProjects().ToList(); } List pendingConflicts = config.Advanced.ProjectMetadataConflicts ??= []; + string sourceKey = BuildMetadataSourceKey( + Path.GetFullPath(rootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); bool metadataConflictChanged = false; IReadOnlyDictionary projectExternalMap = _repo.GetProjectExternalIdMap(); @@ -422,6 +428,7 @@ private MetadataSyncResult ImportFromStoreInternal( mappedProjectId, config, metaProject, + sourceKey, ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); @@ -455,6 +462,7 @@ private MetadataSyncResult ImportFromStoreInternal( existingByName.Id, config, metaProject, + sourceKey, ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); @@ -505,6 +513,12 @@ private MetadataSyncResult ImportFromStoreInternal( TryApplyProjectColor(metaProject.ExternalId, parsedSettings.AvatarColor); if (parsedSettings.HasAutoBackupEnabled) metadataConflictChanged |= ApplyImportedProjectAutoBackupSetting(config, newId, parsedSettings.AutoBackupEnabled); + metadataConflictChanged |= UpsertProjectMetadataMergeBase( + config, + sourceKey, + metaProject, + ResolveProjectWriterMachineId(metaProject, metaInfo), + BuildImportedValues(project, parsedSettings, config, newId)); projectMap[metaProject.ExternalId] = newId; importedProjects++; } @@ -3507,6 +3521,7 @@ private bool ApplyImportedProjectSettings( int projectId, AppConfig config, MetaProject metaProject, + string sourceKey, string? sourceMachineId, ParsedProjectSettings parsedSettings, IList pendingConflicts) @@ -3566,15 +3581,6 @@ private bool ApplyImportedProjectSettings( : currentTags; string? currentKeyRef = string.IsNullOrWhiteSpace(current.EncryptionKeyRef) ? null : current.EncryptionKeyRef; - if (string.Equals(nextPolicy, currentPolicy, StringComparison.OrdinalIgnoreCase) && - string.Equals(nextVerificationPolicy, currentVerificationPolicy, StringComparison.OrdinalIgnoreCase) && - string.Equals(nextPreferredDestinationId, currentPreferredDestinationId, StringComparison.OrdinalIgnoreCase) && - string.Equals(nextRestoreMode, currentRestoreMode, StringComparison.OrdinalIgnoreCase) && - nextAutoBackupEnabled == currentAutoBackupEnabled && - string.Equals(nextTags, currentTags, StringComparison.Ordinal)) - { - return RemoveProjectMetadataConflict(projectId, pendingConflicts); - } bool conflictValuesDiffer = !string.Equals(nextAvatarColor, currentAvatarColor, StringComparison.OrdinalIgnoreCase) || @@ -3606,33 +3612,118 @@ private bool ApplyImportedProjectSettings( Tags = nextTags }; + ProjectMetadataMergeBaseRecord? mergeBase = (config.Advanced.ProjectMetadataMergeBases ??= []) + .FirstOrDefault(item => + string.Equals(item.SourceKey, sourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, metaProject.ExternalId, StringComparison.OrdinalIgnoreCase)); + ProjectMetadataConflictValues? trustedBase = mergeBase is { Revision: > 0 } + ? mergeBase.Values + : null; + ProjectMetadataMergePlan plan = ProjectMetadataMergePlanner.Create(trustedBase, localValues, incomingValues); + if (conflictValuesDiffer && HasDurableKeepLocalResolution(config, metaProject, sourceMachineId, incomingValues)) - return RemoveProjectMetadataConflict(projectId, pendingConflicts); + { + bool changed = RemoveProjectMetadataConflict(projectId, pendingConflicts); + changed |= UpsertProjectMetadataMergeBase(config, sourceKey, metaProject, sourceMachineId, incomingValues); + return changed; + } - if (!conflictValuesDiffer) + if (!plan.HasConflicts) { - if (parsedSettings.HasAvatarColor) - TryApplyProjectColor(metaProject.ExternalId, nextAvatarColor); - _repo.UpdateProjectEncryptionSettings(projectId, nextPolicy, currentKeyRef); - _repo.UpdateProjectPreferredDestination(projectId, nextPreferredDestinationId); - _repo.UpdateProjectRestoreMode(projectId, nextRestoreMode); - _repo.UpdateProjectVerificationPolicy(projectId, nextVerificationPolicy); - _repo.UpdateProjectTags(projectId, nextTags); - if (parsedSettings.HasAutoBackupEnabled) - ApplyImportedProjectAutoBackupSetting(config, projectId, nextAutoBackupEnabled); - return RemoveProjectMetadataConflict(projectId, pendingConflicts); + ApplyProjectMetadataValues(config, current, metaProject.ExternalId, plan.Merged, currentKeyRef); + bool changed = RemoveProjectMetadataConflict(projectId, pendingConflicts); + changed |= UpsertProjectMetadataMergeBase(config, sourceKey, metaProject, sourceMachineId, incomingValues); + return changed; } return UpsertProjectMetadataConflict( new ProjectMetadataConflictContext( current, metaProject, + sourceKey, sourceMachineId, + mergeBase?.Revision ?? 0, + trustedBase ?? new ProjectMetadataConflictValues(), localValues, - incomingValues), + incomingValues, + plan), pendingConflicts); } + private void ApplyProjectMetadataValues( + AppConfig config, + Project current, + string externalId, + ProjectMetadataConflictValues values, + string? currentKeyRef) + { + TryApplyProjectColor(externalId, values.AvatarColor); + _repo.UpdateProjectEncryptionSettings(current.Id, values.EncryptionPolicy, currentKeyRef); + _repo.UpdateProjectPreferredDestination(current.Id, NullIfWhiteSpace(values.PreferredDestinationId)); + _repo.UpdateProjectRestoreMode(current.Id, NullIfWhiteSpace(values.RestoreMode)); + _repo.UpdateProjectVerificationPolicy(current.Id, NullIfWhiteSpace(values.VerificationPolicy)); + _repo.UpdateProjectTags(current.Id, NullIfWhiteSpace(values.Tags)); + if (values.AutoBackupEnabled.HasValue) + ApplyImportedProjectAutoBackupSetting(config, current.Id, values.AutoBackupEnabled.Value); + } + + private static string? NullIfWhiteSpace(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static ProjectMetadataConflictValues BuildImportedValues( + Project project, + ParsedProjectSettings parsed, + AppConfig config, + int projectId) => new() + { + AvatarColor = parsed.HasAvatarColor ? parsed.AvatarColor : string.Empty, + EncryptionPolicy = parsed.HasEncryptionPolicy ? parsed.EncryptionPolicy : project.EncryptionPolicy, + PreferredDestinationId = parsed.HasPreferredDestinationId ? parsed.PreferredDestinationId : project.PreferredDestinationId ?? string.Empty, + RestoreMode = parsed.HasRestoreMode ? parsed.RestoreMode : project.RestoreMode ?? string.Empty, + VerificationPolicy = parsed.HasVerificationPolicy ? parsed.VerificationPolicy : project.VerificationPolicy ?? string.Empty, + AutoBackupEnabled = parsed.HasAutoBackupEnabled + ? parsed.AutoBackupEnabled + : !(config.Backups.AutoBackupDisabledProjects ?? []).Contains(projectId), + Tags = parsed.HasTags ? parsed.Tags : project.Tags ?? string.Empty + }; + + private static bool UpsertProjectMetadataMergeBase( + AppConfig config, + string sourceKey, + MetaProject project, + string? writerMachineId, + ProjectMetadataConflictValues values) + { + config.Advanced.ProjectMetadataMergeBases ??= []; + ProjectMetadataMergeBaseRecord? existing = config.Advanced.ProjectMetadataMergeBases.FirstOrDefault(item => + string.Equals(item.SourceKey, sourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, project.ExternalId, StringComparison.OrdinalIgnoreCase)); + string updatedUtc = project.UpdatedUtc == default + ? string.Empty + : project.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + if (existing is not null && + existing.Revision == project.Revision && + string.Equals(existing.WriterMachineId, writerMachineId, StringComparison.Ordinal) && + string.Equals(existing.UpdatedUtc, updatedUtc, StringComparison.Ordinal) && + ProjectMetadataConflictValuesEqual(existing.Values, values)) + { + return false; + } + + existing ??= new ProjectMetadataMergeBaseRecord + { + SourceKey = sourceKey, + ProjectExternalId = project.ExternalId + }; + if (!config.Advanced.ProjectMetadataMergeBases.Contains(existing)) + config.Advanced.ProjectMetadataMergeBases.Add(existing); + existing.Revision = project.Revision; + existing.WriterMachineId = writerMachineId ?? string.Empty; + existing.UpdatedUtc = updatedUtc; + existing.Values = values; + return true; + } + private static bool HasDurableKeepLocalResolution( AppConfig config, MetaProject project, @@ -3693,11 +3784,18 @@ private static bool UpsertProjectMetadataConflict( ProjectExternalId = string.IsNullOrWhiteSpace(current.ExternalId) ? metaProject.ExternalId : current.ExternalId, ProjectName = current.Name, SourceMachineId = string.IsNullOrWhiteSpace(context.SourceMachineId) ? UnknownAppVersion : context.SourceMachineId, + SourceKey = context.SourceKey, + SourceRevision = metaProject.Revision, + BaseRevision = context.BaseRevision, SourceUpdatedUtc = metaProject.UpdatedUtc == default ? string.Empty : metaProject.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ConflictingFields = [.. context.Plan.ConflictingFields], + Base = context.Base, Local = context.Local, - Imported = context.Incoming + Imported = context.Incoming, + KeepLocalResult = context.Plan.KeepLocalResult, + AcceptImportedResult = context.Plan.AcceptImportedResult }; ProjectMetadataConflictRecord? existing = pendingConflicts.FirstOrDefault(conflict => @@ -3719,8 +3817,15 @@ private static bool UpsertProjectMetadataConflict( existing.ProjectName = next.ProjectName; existing.SourceMachineId = next.SourceMachineId; existing.SourceUpdatedUtc = next.SourceUpdatedUtc; + existing.SourceKey = next.SourceKey; + existing.SourceRevision = next.SourceRevision; + existing.BaseRevision = next.BaseRevision; + existing.ConflictingFields = next.ConflictingFields; + existing.Base = next.Base; existing.Local = next.Local; existing.Imported = next.Imported; + existing.KeepLocalResult = next.KeepLocalResult; + existing.AcceptImportedResult = next.AcceptImportedResult; return true; } @@ -3731,8 +3836,15 @@ private static bool ProjectMetadataConflictEquals(ProjectMetadataConflictRecord string.Equals(left.ProjectName, right.ProjectName, StringComparison.Ordinal) && string.Equals(left.SourceMachineId, right.SourceMachineId, StringComparison.Ordinal) && string.Equals(left.SourceUpdatedUtc, right.SourceUpdatedUtc, StringComparison.Ordinal) && + string.Equals(left.SourceKey, right.SourceKey, StringComparison.OrdinalIgnoreCase) && + left.SourceRevision == right.SourceRevision && + left.BaseRevision == right.BaseRevision && + left.ConflictingFields.SequenceEqual(right.ConflictingFields, StringComparer.Ordinal) && + ProjectMetadataConflictValuesEqual(left.Base, right.Base) && ProjectMetadataConflictValuesEqual(left.Local, right.Local) && - ProjectMetadataConflictValuesEqual(left.Imported, right.Imported); + ProjectMetadataConflictValuesEqual(left.Imported, right.Imported) && + ProjectMetadataConflictValuesEqual(left.KeepLocalResult, right.KeepLocalResult) && + ProjectMetadataConflictValuesEqual(left.AcceptImportedResult, right.AcceptImportedResult); } private static bool ProjectMetadataConflictValuesEqual(ProjectMetadataConflictValues left, ProjectMetadataConflictValues right) diff --git a/src/VaultSync.Core/Services/ProjectMetadataMergePlanner.cs b/src/VaultSync.Core/Services/ProjectMetadataMergePlanner.cs new file mode 100644 index 00000000..318c5682 --- /dev/null +++ b/src/VaultSync.Core/Services/ProjectMetadataMergePlanner.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using VaultSync.Core.Config; + +namespace VaultSync.Core.Services; + +public sealed record ProjectMetadataMergePlan( + ProjectMetadataConflictValues Merged, + ProjectMetadataConflictValues KeepLocalResult, + ProjectMetadataConflictValues AcceptImportedResult, + IReadOnlyList ConflictingFields) +{ + public bool HasConflicts => ConflictingFields.Count > 0; +} + +public static class ProjectMetadataMergePlanner +{ + public static ProjectMetadataMergePlan Create( + ProjectMetadataConflictValues? mergeBase, + ProjectMetadataConflictValues local, + ProjectMetadataConflictValues imported) + { + ArgumentNullException.ThrowIfNull(local); + ArgumentNullException.ThrowIfNull(imported); + + var merged = new ProjectMetadataConflictValues(); + var keepLocal = new ProjectMetadataConflictValues(); + var acceptImported = new ProjectMetadataConflictValues(); + var conflicts = new List(); + + Merge("avatarColor", mergeBase?.AvatarColor, local.AvatarColor, imported.AvatarColor, + StringComparer.OrdinalIgnoreCase, value => merged.AvatarColor = value, + value => keepLocal.AvatarColor = value, value => acceptImported.AvatarColor = value, conflicts); + Merge("encryptionPolicy", mergeBase?.EncryptionPolicy, local.EncryptionPolicy, imported.EncryptionPolicy, + StringComparer.OrdinalIgnoreCase, value => merged.EncryptionPolicy = value, + value => keepLocal.EncryptionPolicy = value, value => acceptImported.EncryptionPolicy = value, conflicts); + Merge("preferredDestinationId", mergeBase?.PreferredDestinationId, local.PreferredDestinationId, imported.PreferredDestinationId, + StringComparer.OrdinalIgnoreCase, value => merged.PreferredDestinationId = value, + value => keepLocal.PreferredDestinationId = value, value => acceptImported.PreferredDestinationId = value, conflicts); + Merge("restoreMode", mergeBase?.RestoreMode, local.RestoreMode, imported.RestoreMode, + StringComparer.OrdinalIgnoreCase, value => merged.RestoreMode = value, + value => keepLocal.RestoreMode = value, value => acceptImported.RestoreMode = value, conflicts); + Merge("verificationPolicy", mergeBase?.VerificationPolicy, local.VerificationPolicy, imported.VerificationPolicy, + StringComparer.OrdinalIgnoreCase, value => merged.VerificationPolicy = value, + value => keepLocal.VerificationPolicy = value, value => acceptImported.VerificationPolicy = value, conflicts); + Merge("autoBackupEnabled", mergeBase?.AutoBackupEnabled, local.AutoBackupEnabled, imported.AutoBackupEnabled, + EqualityComparer.Default, value => merged.AutoBackupEnabled = value, + value => keepLocal.AutoBackupEnabled = value, value => acceptImported.AutoBackupEnabled = value, conflicts); + Merge("tags", mergeBase?.Tags, local.Tags, imported.Tags, + StringComparer.Ordinal, value => merged.Tags = value, + value => keepLocal.Tags = value, value => acceptImported.Tags = value, conflicts); + + return new ProjectMetadataMergePlan(merged, keepLocal, acceptImported, conflicts); + } + + private static void Merge( + string field, + T? baseValue, + T local, + T imported, + IEqualityComparer comparer, + Action setMerged, + Action setKeepLocal, + Action setAcceptImported, + ICollection conflicts) + { + if (comparer.Equals(local, imported)) + { + setMerged(local); + setKeepLocal(local); + setAcceptImported(local); + return; + } + + // With no trusted base, a difference must be reviewed. This is the + // conservative behavior required for stores written before 1.8.7. + if (baseValue is null) + { + conflicts.Add(field); + setMerged(local); + setKeepLocal(local); + setAcceptImported(imported); + return; + } + + bool localChanged = !comparer.Equals(local, baseValue); + bool importedChanged = !comparer.Equals(imported, baseValue); + if (localChanged && importedChanged) + { + conflicts.Add(field); + setMerged(local); + setKeepLocal(local); + setAcceptImported(imported); + return; + } + + T value = importedChanged ? imported : local; + setMerged(value); + setKeepLocal(value); + setAcceptImported(value); + } +} diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs index 7d3c95dc..917801b9 100644 --- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs +++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs @@ -4181,7 +4181,7 @@ await Task.Run(() => var repo = _repositoryFactory.Create(cfg); ProjectMetadataConflictRecord conflict = FindProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId) ?? throw new InvalidOperationException("The metadata conflict is no longer pending."); - ProjectMetadataConflictValues imported = conflict.Imported ?? new ProjectMetadataConflictValues(); + ProjectMetadataConflictValues imported = conflict.AcceptImportedResult ?? conflict.Imported ?? new ProjectMetadataConflictValues(); Project? current = repo.GetProjectById(item.ProjectId); if (current is null) throw new InvalidOperationException("The project no longer exists."); @@ -4201,6 +4201,7 @@ await Task.Run(() => AvatarColorProvider.SetColorForExternalId(conflict.ProjectExternalId, imported.AvatarColor); RecordProjectMetadataResolution(cfg, conflict, "accept-imported"); + AdvanceProjectMetadataMergeBase(cfg, conflict); RemoveProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId); UpdateMetadataConflictTelemetry(cfg, "accept-imported", item.ProjectName, Math.Max(0, cfg.Advanced.ProjectMetadataConflicts.Count)); _configStore.Save(cfg); @@ -4267,7 +4268,20 @@ await Task.Run(() => AppConfig cfg = _configStore.Load(); ProjectMetadataConflictRecord conflict = FindProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId) ?? throw new InvalidOperationException("The metadata conflict is no longer pending."); + var repo = _repositoryFactory.Create(cfg); + Project? current = repo.GetProjectById(item.ProjectId) + ?? throw new InvalidOperationException("The project no longer exists."); + ProjectMetadataConflictValues result = conflict.KeepLocalResult ?? conflict.Local ?? new ProjectMetadataConflictValues(); + repo.UpdateProjectEncryptionSettings(item.ProjectId, result.EncryptionPolicy, current.EncryptionKeyRef); + repo.UpdateProjectPreferredDestination(item.ProjectId, EmptyToNull(result.PreferredDestinationId)); + repo.UpdateProjectRestoreMode(item.ProjectId, EmptyToNull(result.RestoreMode)); + repo.UpdateProjectVerificationPolicy(item.ProjectId, EmptyToNull(result.VerificationPolicy)); + repo.UpdateProjectTags(item.ProjectId, EmptyToNull(result.Tags)); + ApplyResolvedAutoBackupSetting(cfg, item.ProjectId, result.AutoBackupEnabled); + if (!string.IsNullOrWhiteSpace(result.AvatarColor)) + AvatarColorProvider.SetColorForExternalId(conflict.ProjectExternalId, result.AvatarColor); RecordProjectMetadataResolution(cfg, conflict, "keep-local"); + AdvanceProjectMetadataMergeBase(cfg, conflict); RemoveProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId); UpdateMetadataConflictTelemetry(cfg, "keep-local", item.ProjectName, Math.Max(0, cfg.Advanced.ProjectMetadataConflicts.Count)); _configStore.Save(cfg); @@ -4345,13 +4359,19 @@ private static void RecordProjectMetadataResolution( string.Equals(existing.SourceUpdatedUtc, conflict.SourceUpdatedUtc, StringComparison.Ordinal)); cfg.Advanced.ProjectMetadataResolutions.Add(new ProjectMetadataResolutionRecord { + SourceKey = conflict.SourceKey, ProjectExternalId = conflict.ProjectExternalId, SourceMachineId = conflict.SourceMachineId, SourceUpdatedUtc = conflict.SourceUpdatedUtc, + SourceRevision = conflict.SourceRevision, + BaseRevision = conflict.BaseRevision, Decision = decision, ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), Local = conflict.Local ?? new ProjectMetadataConflictValues(), - Imported = conflict.Imported ?? new ProjectMetadataConflictValues() + Imported = conflict.Imported ?? new ProjectMetadataConflictValues(), + Result = string.Equals(decision, "accept-imported", StringComparison.OrdinalIgnoreCase) + ? conflict.AcceptImportedResult ?? conflict.Imported ?? new ProjectMetadataConflictValues() + : conflict.KeepLocalResult ?? conflict.Local ?? new ProjectMetadataConflictValues() }); const int maxResolutionRecords = 100; @@ -4364,6 +4384,25 @@ private static void RecordProjectMetadataResolution( } } + private static void AdvanceProjectMetadataMergeBase(AppConfig cfg, ProjectMetadataConflictRecord conflict) + { + cfg.Advanced.ProjectMetadataMergeBases ??= []; + ProjectMetadataMergeBaseRecord? mergeBase = cfg.Advanced.ProjectMetadataMergeBases.FirstOrDefault(item => + string.Equals(item.SourceKey, conflict.SourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, conflict.ProjectExternalId, StringComparison.OrdinalIgnoreCase)); + mergeBase ??= new ProjectMetadataMergeBaseRecord + { + SourceKey = conflict.SourceKey, + ProjectExternalId = conflict.ProjectExternalId + }; + if (!cfg.Advanced.ProjectMetadataMergeBases.Contains(mergeBase)) + cfg.Advanced.ProjectMetadataMergeBases.Add(mergeBase); + mergeBase.Revision = conflict.SourceRevision; + mergeBase.WriterMachineId = conflict.SourceMachineId; + mergeBase.UpdatedUtc = conflict.SourceUpdatedUtc; + mergeBase.Values = conflict.Imported ?? new ProjectMetadataConflictValues(); + } + private static void ApplyResolvedAutoBackupSetting(AppConfig cfg, int projectId, bool? enabled) { if (!enabled.HasValue) diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs index 26618226..de71b29e 100644 --- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs +++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs @@ -1987,6 +1987,62 @@ public void ImportFromStore_ProjectSettings_RecordsConflictInsteadOfOverwritingT Assert.Empty(AppConfigStore.Load().Advanced.ProjectMetadataConflicts); } + [Fact] + public void ImportFromStore_ProjectSettings_MergesIndependentChangesFromRecordedBase() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + string projectRoot = CreateTempDir(); + DateTime now = DateTime.UtcNow; + + using var configScope = new TestAppConfigScope(); + AppConfigStore.Save(new AppConfig()); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = repo.AddProject(new Project + { + ExternalId = "proj-three-way", + Name = "Three Way", + RootPath = projectRoot, + Preset = "generic", + CreatedUtc = now.AddDays(-1), + RestoreMode = ProjectRestoreMode.Direct, + VerificationPolicy = ProjectVerificationPolicy.Always, + Tags = "base" + }); + + MetadataStore store = CreateStore(metaRoot); + SeedMetaInfo(store, "machine-remote"); + var remote = new MetaProject + { + ExternalId = "proj-three-way", + Name = "Three Way", + Preset = "generic", + RootPathHint = projectRoot, + CreatedUtc = now.AddDays(-1), + SettingsJson = "{\"encryptionPolicy\":\"inherit\",\"preferredDestinationId\":\"\",\"restoreMode\":\"direct\",\"verificationPolicy\":\"always\",\"autoBackupEnabled\":true,\"tags\":\"base\"}", + UpdatedUtc = now, + WriterMachineId = "machine-remote", + Revision = 1 + }; + store.UpsertProject(remote); + + var service = new MetadataSyncService(repo); + Assert.Equal(MetadataSyncStatus.Success, service.ImportFromStore(metaRoot, MetadataSyncOptions.Default).Status); + Assert.Single(AppConfigStore.Load().Advanced.ProjectMetadataMergeBases); + + repo.UpdateProjectTags(projectId, "local"); + remote.SettingsJson = "{\"encryptionPolicy\":\"inherit\",\"preferredDestinationId\":\"\",\"restoreMode\":\"sandbox\",\"verificationPolicy\":\"always\",\"autoBackupEnabled\":true,\"tags\":\"base\"}"; + remote.UpdatedUtc = now.AddMinutes(1); + remote.Revision = 2; + store.UpsertProject(remote); + + Assert.Equal(MetadataSyncStatus.Success, service.ImportFromStore(metaRoot, MetadataSyncOptions.Default).Status); + Project merged = Assert.IsType(repo.GetProjectById(projectId)); + Assert.Equal("local", merged.Tags); + Assert.Equal(ProjectRestoreMode.Sandbox, merged.RestoreMode); + Assert.Empty(AppConfigStore.Load().Advanced.ProjectMetadataConflicts); + } + [Fact] public void ExportBackupToStore_ProjectSettings_ExcludesMachineLocalEncryptionKeyRef() { diff --git a/tests/VaultSync.Core.Tests/ProjectMetadataMergePlannerTests.cs b/tests/VaultSync.Core.Tests/ProjectMetadataMergePlannerTests.cs new file mode 100644 index 00000000..e1fb40c9 --- /dev/null +++ b/tests/VaultSync.Core.Tests/ProjectMetadataMergePlannerTests.cs @@ -0,0 +1,76 @@ +using VaultSync.Core.Config; +using VaultSync.Core.Services; +using Xunit; + +namespace VaultSync.Core.Tests; + +public sealed class ProjectMetadataMergePlannerTests +{ + [Fact] + public void Create_MergesIndependentFieldEdits() + { + ProjectMetadataConflictValues mergeBase = Values(tags: "base", restoreMode: "direct"); + ProjectMetadataConflictValues local = Values(tags: "local", restoreMode: "direct"); + ProjectMetadataConflictValues imported = Values(tags: "base", restoreMode: "staged"); + + ProjectMetadataMergePlan plan = ProjectMetadataMergePlanner.Create(mergeBase, local, imported); + + Assert.False(plan.HasConflicts); + Assert.Equal("local", plan.Merged.Tags); + Assert.Equal("staged", plan.Merged.RestoreMode); + } + + [Fact] + public void Create_FlagsOnlyOverlappingEdits() + { + ProjectMetadataConflictValues mergeBase = Values(tags: "base", restoreMode: "direct"); + ProjectMetadataConflictValues local = Values(tags: "local", restoreMode: "direct"); + ProjectMetadataConflictValues imported = Values(tags: "remote", restoreMode: "staged"); + + ProjectMetadataMergePlan plan = ProjectMetadataMergePlanner.Create(mergeBase, local, imported); + + Assert.Equal(["tags"], plan.ConflictingFields); + Assert.Equal("local", plan.KeepLocalResult.Tags); + Assert.Equal("remote", plan.AcceptImportedResult.Tags); + Assert.Equal("staged", plan.KeepLocalResult.RestoreMode); + Assert.Equal("staged", plan.AcceptImportedResult.RestoreMode); + } + + [Fact] + public void Create_DoesNotConflictWhenBothMachinesMadeTheSameEdit() + { + ProjectMetadataConflictValues mergeBase = Values(tags: "base"); + ProjectMetadataConflictValues local = Values(tags: "shared"); + ProjectMetadataConflictValues imported = Values(tags: "shared"); + + ProjectMetadataMergePlan plan = ProjectMetadataMergePlanner.Create(mergeBase, local, imported); + + Assert.False(plan.HasConflicts); + Assert.Equal("shared", plan.Merged.Tags); + } + + [Fact] + public void Create_WithoutTrustedBase_RequiresReviewForDifferences() + { + ProjectMetadataConflictValues local = Values(tags: "local", restoreMode: "direct"); + ProjectMetadataConflictValues imported = Values(tags: "remote", restoreMode: "staged"); + + ProjectMetadataMergePlan plan = ProjectMetadataMergePlanner.Create(null, local, imported); + + Assert.Contains("tags", plan.ConflictingFields); + Assert.Contains("restoreMode", plan.ConflictingFields); + Assert.Equal("local", plan.KeepLocalResult.Tags); + Assert.Equal("remote", plan.AcceptImportedResult.Tags); + } + + private static ProjectMetadataConflictValues Values(string tags, string restoreMode = "direct") => new() + { + AvatarColor = "#123456", + EncryptionPolicy = "inherit", + PreferredDestinationId = string.Empty, + RestoreMode = restoreMode, + VerificationPolicy = "always", + AutoBackupEnabled = true, + Tags = tags + }; +} From 209387f2031304a389380b105c3d6a00c9e47cd4 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:00:34 +0200 Subject: [PATCH 49/54] Reconcile the 1.8.7 release plan --- CHANGELOG.md | 2 ++ ROADMAP.md | 29 +++++++++++++++++------------ docs/CROSS_MACHINE_SAFETY.md | 4 +++- docs/RELEASE_1.8.7.md | 8 +++++++- docs/REPOSITORY_FORMATS.md | 3 ++- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72137108..106586d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ - [VS-1877] Added a durable, owner-private installation identity for cross-machine coordination without treating mutable host names or telemetry identifiers as writer identity. - [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. - [VS-1877] Added per-destination repository-writer inspection and an explicit stale-takeover review that shows the owner, operation, version, heartbeat, and expiry before preserving the old lease as evidence. +- [VS-1879] Added durable per-source merge bases and a field-level three-way metadata planner so independent cross-machine edits merge automatically while overlapping edits remain explicitly reviewable. ### Changed - [BUG-18099] Serviced the .NET 10 baseline to SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft packages, with CI auditing real self-contained publishes and release artifacts for every supported runtime identifier. - [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. - [VS-1877] Made unavailable-destination metadata queues fail closed: queued metadata can initialize an empty destination once, while an existing destination is preserved for explicit merge review. +- [VS-1879] Made conflict decisions preserve non-overlapping remote edits, record source and base revisions, and advance the durable merge base after either resolution. - [VS-1880] Consolidated metadata export orchestration, SMB mount parsing, mounted-share validation, theme color normalization, and contrast calculations behind focused shared primitives with regression coverage. - [VS-1880] Unified Windows Robocopy exclusions with the shared preset resolver. - [BUG-18103] Modernized Snapshot Explorer, metadata-import review, and updater windows around the current compact, theme-aware app layout. diff --git a/ROADMAP.md b/ROADMAP.md index 755f14b6..b641d723 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -468,6 +468,11 @@ after the planned minor train is complete and use explicit beta qualification. another; non-overlapping changes merge, overlapping changes show old, local, and remote values with timestamps and writers; accepting either side is durable and auditable; the operation can be undone before the next write. + - In progress on 2026-08-16 in PR #546: durable per-source merge bases, + field-level three-way planning, automatic non-overlapping merges, and + resolution results that retain independent edits are implemented and + tested. Guarded repository writes, exported provenance, and bounded undo + remain before completion. - [ ] `VS-1880` `P1` Simplify and standardize shared application code without changing user-visible behavior. - Scope: consolidate repeated retry, path, serialization, status, dialog, @@ -563,18 +568,18 @@ after the planned minor train is complete and use explicit beta qualification. ### Delivery sequence -1. **12–16 August:** contracts, issue repair, threat model, metadata schema. -2. **17–30 August:** release manifest and in-app build identity. -3. **31 August–13 September:** platform SBOMs and artifact provenance. -4. **14–27 September:** durable machine identity, repository lease, and - cross-machine three-way merge. -5. **28 September–11 October:** evidence package, support bundle, and repository - documentation. -6. **12–18 October:** public metadata consumers, UI polish, localization, and - release dry run. -7. **19–25 October:** unpublished candidate, multi-machine/NAS exercises, and - the complete Windows, macOS, and Linux qualification matrix. -8. **30 October:** Stable release after every P0 and release gate is complete. +1. **12–16 August:** contracts, issue repair, release manifest, serviced runtime, + machine identity, writer leases, and the first three-way merge slice. +2. **17–19 August:** finish guarded cross-machine writes, provenance, resolution + export, bounded undo, and two-machine safety fixtures. +3. **20–21 August:** build identity, platform SBOM/provenance, Recovery Evidence + Package, and reviewed support export. +4. **22 August:** repository and public metadata synchronization plus bounded + code cleanup. +5. **23 August:** unpublished stable-candidate qualification across supported + platforms, upgrade paths, storage, localization, accessibility, and security. +6. **24 August:** merge through `Dev` to `Stable`, publish, verify, and close the + milestone. ### Release gates diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index 513480b9..5397f650 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -148,7 +148,9 @@ revision. migration or repair writer must enter through the same boundary.** 4. Add the versioned schema and forward migration fixtures. **Version-2 project writer/revision columns and version-1 compatibility were - implemented on 2026-08-16; base revisions remain in the VS-1879 merge work.** + implemented on 2026-08-16. Durable per-source base revisions and field-level + three-way planning were implemented on 2026-08-16; guarded repository writes, + exported provenance, and bounded undo remain in the VS-1879 merge work.** 5. Produce merge plans without applying them. 6. Add explicit apply, durable resolution, and bounded undo. **Durable Keep local and Accept imported decisions are implemented and diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 4fc4c8f6..938a55ad 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -105,6 +105,11 @@ complete and stable enough for broader qualification. preserves manually maintained issue bodies, constrains file inputs to the repository, validates every remote identifier, and provides an exact structured dry run before any Project or issue write (`BUG-18098`). +- Cross-machine project settings now use durable per-source merge bases and a + field-level three-way planner. Independent local and remote edits merge + automatically; only overlapping fields require review. Conflict records + retain source/base revisions and both decisions preserve non-overlapping + work before advancing the durable base (`VS-1879`). These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -113,7 +118,8 @@ branch package. ### In progress next -1. Make metadata imports previewable, versioned, durable, and reversible. +1. Finish guarded versioned repository writes, provenance export, and bounded + undo for resolved metadata merges. 2. Expose the running build and canonical release identity through the app, CLI, diagnostics, and support exports. 3. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index 4710482c..726c93cf 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -61,7 +61,8 @@ destination configured locally. The coordination database, durable installation identity, protection of all existing metadata writers, and version-2 per-project writer/revision fields are -implemented on the 1.8.7 release branch. The remaining three-way merge work will +implemented on the 1.8.7 release branch. Durable local merge bases and the +field-level three-way planner are also implemented. The remaining merge work will add, with explicit migrations and fixtures: - base revision and field-level provenance beyond the current project writer, From 551418562cc38e1d30e05995e189e544377f9d81 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:50:49 +0200 Subject: [PATCH 50/54] Guard project metadata writes by revision --- src/VaultSync.Core/Services/MetadataStore.cs | 59 +++++ .../Services/MetadataSyncService.cs | 234 +++++++++++++----- .../VaultSync.Core.Tests/MetadataSyncTests.cs | 153 ++++++++++++ 3 files changed, 390 insertions(+), 56 deletions(-) diff --git a/src/VaultSync.Core/Services/MetadataStore.cs b/src/VaultSync.Core/Services/MetadataStore.cs index bb17ccda..456974c2 100644 --- a/src/VaultSync.Core/Services/MetadataStore.cs +++ b/src/VaultSync.Core/Services/MetadataStore.cs @@ -228,6 +228,47 @@ ELSE projects.revision + 1 }); } + public bool TryUpsertProject(MetaProject project, long expectedRevision) + { + if (expectedRevision < 0) + throw new ArgumentOutOfRangeException(nameof(expectedRevision)); + + const string sql = """ + INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision) + SELECT @ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @NextRevision + WHERE @ExpectedRevision = 0 + OR EXISTS( + SELECT 1 + FROM projects + WHERE external_id = @ExternalId + AND revision = @ExpectedRevision) + ON CONFLICT(external_id) DO UPDATE SET + name = excluded.name, + preset = excluded.preset, + root_path_hint = excluded.root_path_hint, + settings_json = excluded.settings_json, + updated_utc = excluded.updated_utc, + writer_machine_id = excluded.writer_machine_id, + revision = excluded.revision + WHERE projects.revision = @ExpectedRevision; + """; + var parameters = new + { + project.ExternalId, + project.Name, + project.Preset, + project.RootPathHint, + CreatedUtc = ToUtcString(project.CreatedUtc), + project.SettingsJson, + UpdatedUtc = ToUtcString(project.UpdatedUtc), + project.WriterMachineId, + ExpectedRevision = expectedRevision, + NextRevision = checked(expectedRevision + 1) + }; + + return ExecuteWriteCount(sql, parameters) == 1; + } + public void UpsertSnapshot(MetaSnapshot snapshot) { ExecuteWrite( @@ -350,6 +391,15 @@ private void ExecuteWrite(string sql, object? param = null) connection.Execute(sql, param); } + private int ExecuteWriteCount(string sql, object? param = null) + { + if (_activeWriteConnection is not null) + return _activeWriteConnection.Execute(sql, param, _activeWriteTransaction); + + using SqliteConnection connection = Open(write: true); + return connection.Execute(sql, param); + } + public IEnumerable ListProjects() { using SqliteConnection? c = TryOpenRead(); @@ -404,6 +454,15 @@ public bool HasProject(string externalId) new { id = externalId }) == 1; } + public MetaProject? GetProject(string externalId) + { + if (string.IsNullOrWhiteSpace(externalId)) + return null; + + return ListProjects().FirstOrDefault(project => + string.Equals(project.ExternalId, externalId, StringComparison.OrdinalIgnoreCase)); + } + public IEnumerable ListSnapshots() { using SqliteConnection? c = TryOpenRead(); diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 64c452c8..fd3ac476 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -50,7 +50,16 @@ private sealed record BackupExportWriteContext( string BackupExternalId, DateTime Now, string MachineId, - bool ForceBackfill); + bool ForceBackfill, + MetaProject ProjectRecord, + long ExpectedProjectRevision); + + private sealed record GuardedProjectWrite( + MetaProject Record, + long ExpectedRevision, + ProjectMetadataConflictValues Values); + + private sealed class MetadataRevisionConflictException(string message) : InvalidOperationException(message); private sealed record LegacyPreviewContext( string RootPath, @@ -2442,6 +2451,18 @@ private MetadataSyncResult ExportProjectToStoreInternal( appVersion, machineId, updateExistingAppVersion: true); + if (!TryPrepareGuardedProjectWrite( + rootPath, + store, + project, + projectExternalId, + now, + machineId, + out GuardedProjectWrite? guardedWrite, + out string revisionFailure)) + { + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, revisionFailure); + } try { @@ -2450,19 +2471,14 @@ private MetadataSyncResult ExportProjectToStoreInternal( store.ExecuteWriteBatch(() => { store.UpsertMetaInfo(metaInfo); - store.UpsertProject(new MetaProject - { - ExternalId = projectExternalId, - Name = project.Name, - Preset = project.Preset, - RootPathHint = project.RootPath, - CreatedUtc = project.CreatedUtc, - SettingsJson = BuildProjectSettingsJson(project), - UpdatedUtc = now, - WriterMachineId = machineId - }); + if (!store.TryUpsertProject(guardedWrite!.Record, guardedWrite.ExpectedRevision)) + throw new MetadataRevisionConflictException("Project metadata changed after its revision was inspected."); }); } + catch (MetadataRevisionConflictException ex) + { + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, ex.Message); + } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { Console.WriteLine($"[MetadataSync] Project export failed writing store '{rootPath}': {ex.Message}"); @@ -2479,6 +2495,9 @@ private MetadataSyncResult ExportProjectToStoreInternal( Console.WriteLine($"[MetadataSync] Project export complete for project '{project.Name}' to '{storeRoot}'."); LogStoreCounts(store); + if (!useDeferredStore) + SaveSuccessfulProjectWriteBase(rootPath, guardedWrite!); + if (useDeferredStore) { return MetadataSyncResult.Failure( @@ -2594,6 +2613,18 @@ private MetadataSyncResult ExportBackupToStoreInternal( appVersion, machineId, updateExistingAppVersion: true); + if (!TryPrepareGuardedProjectWrite( + rootPath, + store, + project, + projectExternalId, + now, + machineId, + out GuardedProjectWrite? guardedWrite, + out string revisionFailure)) + { + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, revisionFailure); + } BackupExportCounts counts; try @@ -2610,7 +2641,13 @@ private MetadataSyncResult ExportBackupToStoreInternal( backupExternalId, now, machineId, - forceBackfill)); + forceBackfill, + guardedWrite!.Record, + guardedWrite.ExpectedRevision)); + } + catch (MetadataRevisionConflictException ex) + { + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, ex.Message); } catch (Exception ex) when (ex is not SqliteException sqliteEx || !IsCannotOpenOrLocked(sqliteEx)) { @@ -2629,6 +2666,8 @@ private MetadataSyncResult ExportBackupToStoreInternal( ? $"[MetadataSync] Export complete (backfill) for project '{project.Name}' to '{storeRoot}': snapshots={counts.Snapshots}, backups={counts.Backups}." : $"[MetadataSync] Export complete for backup {backupId} to '{storeRoot}'."); LogStoreCounts(store); + if (!useDeferredStore) + SaveSuccessfulProjectWriteBase(rootPath, guardedWrite!); if (useDeferredStore) { return MetadataSyncResult.Failure( @@ -2698,24 +2737,17 @@ private BackupExportCounts WriteBackupExport( entities.Project, context.ProjectExternalId, context.Now, - context.MachineId); + context.MachineId, + context.ProjectRecord, + context.ExpectedProjectRevision); exportedProjects = 1; exportedSnapshots = snapshots; exportedBackups = backups; return; } - store.UpsertProject(new MetaProject - { - ExternalId = context.ProjectExternalId, - Name = entities.Project.Name, - Preset = entities.Project.Preset, - RootPathHint = entities.Project.RootPath, - CreatedUtc = entities.Project.CreatedUtc, - SettingsJson = BuildProjectSettingsJson(entities.Project), - UpdatedUtc = context.Now, - WriterMachineId = context.MachineId - }); + if (!store.TryUpsertProject(context.ProjectRecord, context.ExpectedProjectRevision)) + throw new MetadataRevisionConflictException("Project metadata changed after its revision was inspected."); store.UpsertSnapshot(new MetaSnapshot { ExternalId = context.SnapshotExternalId, @@ -3199,24 +3231,17 @@ private static bool TryCopyStoreFiles(string fromRoot, string toRoot) Project project, string projectExternalId, DateTime now, - string machineId) + string machineId, + MetaProject projectRecord, + long expectedProjectRevision) { var snapshots = _repo.GetSnapshotsForProject(project.Name).ToList(); var backups = _repo.GetBackupsForProject(project.Id).ToList(); Console.WriteLine($"[MetadataSync] Export history for '{project.Name}': snapshots={snapshots.Count}, backups={backups.Count}."); var snapshotExternalIds = new Dictionary(); - store.UpsertProject(new MetaProject - { - ExternalId = projectExternalId, - Name = project.Name, - Preset = project.Preset, - RootPathHint = project.RootPath, - CreatedUtc = project.CreatedUtc, - SettingsJson = BuildProjectSettingsJson(project), - UpdatedUtc = now, - WriterMachineId = machineId - }); + if (!store.TryUpsertProject(projectRecord, expectedProjectRevision)) + throw new MetadataRevisionConflictException("Project metadata changed after its revision was inspected."); foreach (Snapshot? snap in snapshots) { @@ -3358,37 +3383,134 @@ private string EnsureBackupExternalId(Backup backup) private static string NewExternalId() => Guid.NewGuid().ToString("N"); - private string BuildProjectSettingsJson(Project project) + private bool TryPrepareGuardedProjectWrite( + string destinationRoot, + MetadataStore store, + Project project, + string projectExternalId, + DateTime updatedUtc, + string machineId, + out GuardedProjectWrite? write, + out string failure) { + write = null; + failure = string.Empty; try { - string? color = _projectColorResolver?.Invoke(project); - var settings = new Dictionary(); - if (!string.IsNullOrWhiteSpace(color)) + MetaProject? existing = store.GetProject(projectExternalId); + long expectedRevision; + AppConfig config = _configStore.Load(); + string sourceKey = BuildMetadataSourceKey( + Path.GetFullPath(destinationRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + ProjectMetadataMergeBaseRecord? mergeBase = (config.Advanced.ProjectMetadataMergeBases ?? []) + .FirstOrDefault(item => + string.Equals(item.SourceKey, sourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, projectExternalId, StringComparison.OrdinalIgnoreCase)); + + if (existing is null) + { + expectedRevision = 0; + } + else if (mergeBase is not null) { - settings["avatarColor"] = color; + if (mergeBase.Revision <= 0 || mergeBase.Revision != existing.Revision) + { + failure = "Project metadata changed on another machine. Import and review that revision before writing."; + return false; + } + + expectedRevision = mergeBase.Revision; + } + else if (!string.IsNullOrWhiteSpace(existing.WriterMachineId) && + string.Equals(existing.WriterMachineId, machineId, StringComparison.Ordinal)) + { + expectedRevision = existing.Revision; + } + else + { + failure = "Existing project metadata has no trusted local base. Import and review it before writing."; + return false; } - settings["encryptionPolicy"] = ProjectEncryptionPolicy.Normalize(project.EncryptionPolicy); - settings["preferredDestinationId"] = string.IsNullOrWhiteSpace(project.PreferredDestinationId) - ? null - : project.PreferredDestinationId; - settings["restoreMode"] = ProjectRestoreMode.Normalize(project.RestoreMode); - settings["verificationPolicy"] = ProjectVerificationPolicy.Normalize(project.VerificationPolicy); - List disabledProjects = _configStore.GetSnapshot().Backups.AutoBackupDisabledProjects ?? []; - settings["autoBackupEnabled"] = !disabledProjects.Contains(project.Id); - settings["tags"] = string.IsNullOrWhiteSpace(project.Tags) - ? string.Empty - : project.Tags.Trim(); + ProjectMetadataConflictValues values = BuildExportedProjectValues(project, config); + var record = new MetaProject + { + ExternalId = projectExternalId, + Name = project.Name, + Preset = project.Preset, + RootPathHint = project.RootPath, + CreatedUtc = project.CreatedUtc, + SettingsJson = SerializeProjectSettings(values), + UpdatedUtc = updatedUtc, + WriterMachineId = machineId, + Revision = checked(expectedRevision + 1) + }; + write = new GuardedProjectWrite(record, expectedRevision, values); + return true; + } + catch (Exception ex) + { + RuntimeLog.WriteVerbose($"[MetadataSync] Could not prepare guarded project metadata: {ex.Message}"); + failure = "Project metadata could not be prepared safely for writing."; + return false; + } + } - return JsonSerializer.Serialize(settings); + private void SaveSuccessfulProjectWriteBase(string destinationRoot, GuardedProjectWrite write) + { + try + { + AppConfig config = _configStore.Load(); + string sourceKey = BuildMetadataSourceKey( + Path.GetFullPath(destinationRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (UpsertProjectMetadataMergeBase( + config, + sourceKey, + write.Record, + write.Record.WriterMachineId, + write.Values)) + { + _configStore.Save(config); + } } - catch + catch (Exception ex) { - return "{}"; + RuntimeLog.WriteVerbose($"[MetadataSync] Could not persist the successful project write base: {ex.Message}"); } } + private ProjectMetadataConflictValues BuildExportedProjectValues(Project project, AppConfig config) + { + string? color = _projectColorResolver?.Invoke(project); + List disabledProjects = config.Backups.AutoBackupDisabledProjects ?? []; + return new ProjectMetadataConflictValues + { + AvatarColor = NormalizeAvatarColor(color), + EncryptionPolicy = ProjectEncryptionPolicy.Normalize(project.EncryptionPolicy), + PreferredDestinationId = project.PreferredDestinationId?.Trim() ?? string.Empty, + RestoreMode = ProjectRestoreMode.Normalize(project.RestoreMode), + VerificationPolicy = ProjectVerificationPolicy.Normalize(project.VerificationPolicy), + AutoBackupEnabled = !disabledProjects.Contains(project.Id), + Tags = project.Tags?.Trim() ?? string.Empty + }; + } + + private static string SerializeProjectSettings(ProjectMetadataConflictValues values) + { + var settings = new Dictionary(); + if (!string.IsNullOrWhiteSpace(values.AvatarColor)) + settings["avatarColor"] = values.AvatarColor; + settings["encryptionPolicy"] = values.EncryptionPolicy; + settings["preferredDestinationId"] = string.IsNullOrWhiteSpace(values.PreferredDestinationId) + ? null + : values.PreferredDestinationId; + settings["restoreMode"] = values.RestoreMode; + settings["verificationPolicy"] = values.VerificationPolicy; + settings["autoBackupEnabled"] = values.AutoBackupEnabled; + settings["tags"] = values.Tags; + return JsonSerializer.Serialize(settings); + } + private readonly record struct ParsedProjectSettings( string AvatarColor, string EncryptionPolicy, diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs index de71b29e..f8ad0fdd 100644 --- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs +++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs @@ -1525,6 +1525,57 @@ public void ExportBackupToStore_WritesMetadata() Assert.False(string.IsNullOrWhiteSpace(updatedBackup?.ExternalId)); } + [Fact] + public void ExportBackupToStore_RejectsUnreviewedExistingProjectRevision() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + using var configScope = new TestAppConfigScope(); + AppConfigStore.Save(new AppConfig()); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = repo.AddProject(new Project + { + ExternalId = "project-unreviewed-export", + Name = "Unreviewed Export", + RootPath = CreateTempDir(), + Preset = "generic", + CreatedUtc = DateTime.UtcNow.AddDays(-1), + Tags = "local" + }); + int snapshotId = repo.CreateSnapshot(projectId, 1, 100); + int backupId = repo.CreateBackup( + projectId, + snapshotId, + "manual", + 100, + "unreviewed/backup", + metaRoot, + "Primary"); + MetadataStore store = CreateStore(metaRoot); + store.UpsertProject(new MetaProject + { + ExternalId = "project-unreviewed-export", + Name = "Remote", + Preset = "generic", + RootPathHint = "/remote", + CreatedUtc = DateTime.UtcNow.AddDays(-1), + SettingsJson = "{\"tags\":\"remote\"}", + UpdatedUtc = DateTime.UtcNow, + WriterMachineId = "machine-remote", + Revision = 4 + }); + + var service = new MetadataSyncService(repo); + MetadataSyncResult result = service.ExportBackupToStore(metaRoot, backupId, "1.8.7", "machine-local"); + + Assert.Equal(MetadataSyncStatus.RepositoryBusy, result.Status); + Assert.Contains("no trusted local base", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(store.ListBackups()); + MetaProject retained = Assert.IsType(store.GetProject("project-unreviewed-export")); + Assert.Equal("machine-remote", retained.WriterMachineId); + Assert.Equal(4, retained.Revision); + } + [Fact] public void ExportBackupToStore_ActiveWriterBlocksMetadataMutation() { @@ -1570,6 +1621,54 @@ public void ExportProjectToStore_ActiveWriterBlocksMetadataMutation() Assert.True(writer.IsOwner); } + [Fact] + public void ExportProjectToStore_RejectsRevisionThatChangedAfterTheLocalBase() + { + string metaRoot = CreateTempDir(); + string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db"); + using var configScope = new TestAppConfigScope(); + AppConfigStore.Save(new AppConfig()); + SqliteRepository repo = CreateRepository(dbPath); + int projectId = repo.AddProject(new Project + { + ExternalId = "project-guarded-export", + Name = "Guarded Export", + RootPath = CreateTempDir(), + Preset = "generic", + CreatedUtc = DateTime.UtcNow.AddDays(-1), + Tags = "local-one" + }); + var service = new MetadataSyncService(repo); + + MetadataSyncResult first = service.ExportProjectToStore(metaRoot, projectId, "1.8.7", "machine-local"); + Assert.Equal(MetadataSyncStatus.Success, first.Status); + MetadataStore store = new(metaRoot); + MetaProject firstRecord = Assert.IsType(store.GetProject("project-guarded-export")); + Assert.Equal(1, firstRecord.Revision); + + repo.UpdateProjectTags(projectId, "local-two"); + MetadataSyncResult second = service.ExportProjectToStore(metaRoot, projectId, "1.8.7", "machine-local"); + Assert.Equal(MetadataSyncStatus.Success, second.Status); + MetaProject secondRecord = Assert.IsType(store.GetProject("project-guarded-export")); + Assert.Equal(2, secondRecord.Revision); + + secondRecord.SettingsJson = "{\"tags\":\"remote-three\"}"; + secondRecord.WriterMachineId = "machine-remote"; + secondRecord.UpdatedUtc = DateTime.UtcNow.AddMinutes(1); + secondRecord.Revision = 3; + store.UpsertProject(secondRecord); + + repo.UpdateProjectTags(projectId, "stale-local-three"); + MetadataSyncResult stale = service.ExportProjectToStore(metaRoot, projectId, "1.8.7", "machine-local"); + + Assert.Equal(MetadataSyncStatus.RepositoryBusy, stale.Status); + Assert.Contains("another machine", stale.Message, StringComparison.OrdinalIgnoreCase); + MetaProject retained = Assert.IsType(store.GetProject("project-guarded-export")); + Assert.Equal(3, retained.Revision); + Assert.Equal("machine-remote", retained.WriterMachineId); + Assert.Contains("remote-three", retained.SettingsJson, StringComparison.Ordinal); + } + [Fact] public void ExportBackupToStore_DeferredQueueFlushesOnceAndIsRemoved() { @@ -1749,6 +1848,60 @@ public void MetadataStore_ProjectRows_RecordWriterAndAdvanceRevision() Assert.Equal(2, stored.Revision); } + [Fact] + public void MetadataStore_TryUpsertProject_RejectsAStaleExpectedRevision() + { + MetadataStore store = CreateStore(CreateTempDir()); + var project = new MetaProject + { + ExternalId = "project-guarded-stale", + Name = "Original", + Preset = "generic", + RootPathHint = CreateTempDir(), + CreatedUtc = DateTime.UtcNow.AddDays(-1), + SettingsJson = "{\"tags\":\"original\"}", + UpdatedUtc = DateTime.UtcNow, + WriterMachineId = "machine-a" + }; + + Assert.True(store.TryUpsertProject(project, expectedRevision: 0)); + project.Name = "Second"; + project.SettingsJson = "{\"tags\":\"second\"}"; + project.WriterMachineId = "machine-b"; + Assert.True(store.TryUpsertProject(project, expectedRevision: 1)); + + project.Name = "Stale overwrite"; + project.SettingsJson = "{\"tags\":\"stale\"}"; + project.WriterMachineId = "machine-a"; + Assert.False(store.TryUpsertProject(project, expectedRevision: 1)); + + MetaProject stored = Assert.IsType(store.GetProject(project.ExternalId)); + Assert.Equal("Second", stored.Name); + Assert.Equal("{\"tags\":\"second\"}", stored.SettingsJson); + Assert.Equal("machine-b", stored.WriterMachineId); + Assert.Equal(2, stored.Revision); + } + + [Fact] + public void MetadataStore_TryUpsertProject_DoesNotInsertWhenExpectedRevisionIsMissing() + { + MetadataStore store = CreateStore(CreateTempDir()); + var project = new MetaProject + { + ExternalId = "project-guarded-missing", + Name = "Missing", + Preset = "generic", + RootPathHint = CreateTempDir(), + CreatedUtc = DateTime.UtcNow, + SettingsJson = "{}", + UpdatedUtc = DateTime.UtcNow, + WriterMachineId = "machine-a" + }; + + Assert.False(store.TryUpsertProject(project, expectedRevision: 4)); + Assert.Null(store.GetProject(project.ExternalId)); + } + [Fact] public void ImportFromStore_ProjectSettings_AppliesEncryptionPolicyButNeverImportsKeyRef() { From e7e4c8cda0308a24d668f5a24c9e3f4f90e218ec Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:55:18 +0200 Subject: [PATCH 51/54] Export metadata merge provenance --- CHANGELOG.md | 1 + ROADMAP.md | 5 +- docs/CROSS_MACHINE_SAFETY.md | 5 +- docs/RELEASE_1.8.7.md | 8 +- docs/REPOSITORY_FORMATS.md | 32 +++-- src/VaultSync.Core/Config/AppConfig.cs | 8 ++ src/VaultSync.Core/Config/AppConfigStore.cs | 12 +- src/VaultSync.Core/Services/MetadataStore.cs | 50 +++++-- .../Services/MetadataSyncService.cs | 127 +++++++++++++++++- .../VaultSync.Core.Tests/MetadataSyncTests.cs | 61 ++++++++- 10 files changed, 272 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 106586d2..abb2e271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. - [VS-1877] Made unavailable-destination metadata queues fail closed: queued metadata can initialize an empty destination once, while an existing destination is preserved for explicit merge review. - [VS-1879] Made conflict decisions preserve non-overlapping remote edits, record source and base revisions, and advance the durable merge base after either resolution. +- [VS-1879] Guarded every portable project writer with compare-and-swap revisions and upgraded project records to schema version 3 with base revision, per-field writer/timestamp provenance, and safe resolution evidence. - [VS-1880] Consolidated metadata export orchestration, SMB mount parsing, mounted-share validation, theme color normalization, and contrast calculations behind focused shared primitives with regression coverage. - [VS-1880] Unified Windows Robocopy exclusions with the shared preset resolver. - [BUG-18103] Modernized Snapshot Explorer, metadata-import review, and updater windows around the current compact, theme-aware app layout. diff --git a/ROADMAP.md b/ROADMAP.md index b641d723..bc50cd9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -471,8 +471,9 @@ after the planned minor train is complete and use explicit beta qualification. - In progress on 2026-08-16 in PR #546: durable per-source merge bases, field-level three-way planning, automatic non-overlapping merges, and resolution results that retain independent edits are implemented and - tested. Guarded repository writes, exported provenance, and bounded undo - remain before completion. + tested. Guarded repository writes and schema-version-3 base/provenance and + resolution export followed on 2026-08-17. Base/local/remote presentation, + bounded undo, and final two-machine qualification remain before completion. - [ ] `VS-1880` `P1` Simplify and standardize shared application code without changing user-visible behavior. - Scope: consolidate repeated retry, path, serialization, status, dialog, diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index 5397f650..e7c44444 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -149,8 +149,9 @@ revision. 4. Add the versioned schema and forward migration fixtures. **Version-2 project writer/revision columns and version-1 compatibility were implemented on 2026-08-16. Durable per-source base revisions and field-level - three-way planning were implemented on 2026-08-16; guarded repository writes, - exported provenance, and bounded undo remain in the VS-1879 merge work.** + three-way planning were implemented on 2026-08-16. Schema-version-3 guarded + writes, base revisions, field provenance, and safe resolution export were + implemented on 2026-08-17; bounded undo remains in the VS-1879 merge work.** 5. Produce merge plans without applying them. 6. Add explicit apply, durable resolution, and bounded undo. **Durable Keep local and Accept imported decisions are implemented and diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 938a55ad..66891cbe 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -110,6 +110,10 @@ complete and stable enough for broader qualification. automatically; only overlapping fields require review. Conflict records retain source/base revisions and both decisions preserve non-overlapping work before advancing the durable base (`VS-1879`). +- Every portable project writer now advances only the exact revision it + inspected. Schema-version-3 rows carry their base revision, per-field writer, + revision and timestamp provenance, plus the latest safe resolution evidence; + stale writes roll back without replacing remote metadata (`VS-1879`). These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -118,8 +122,8 @@ branch package. ### In progress next -1. Finish guarded versioned repository writes, provenance export, and bounded - undo for resolved metadata merges. +1. Finish Base/local/remote conflict presentation and bounded undo for resolved + metadata merges. 2. Expose the running build and canonical release identity through the app, CLI, diagnostics, and support exports. 3. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index 726c93cf..b39ac5dd 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -23,12 +23,13 @@ mechanism. ## Portable metadata store — Current -The SQLite metadata store uses schema version `1`. Its logical tables are: +The SQLite metadata store uses schema version `3`. Its logical tables are: - `meta_info`: schema version, creation/write timestamps, writer app version, and the most recent store-level writer machine value; - `projects`: external identity, name, preset, root-path hint, timestamps, - per-record writer/revision identity, and JSON-encoded project settings; + per-record writer/revision/base identity, field-level provenance, the latest + safe resolution evidence, and JSON-encoded project settings; - `snapshots`: external/project identities, creation time, counts, sizes, and diff summaries; - `backups`: external/project/snapshot identities, creation time, backup type @@ -51,26 +52,23 @@ destination configured locally. not authoritative local paths. - Plaintext credentials and backup payload contents are not stored in the metadata database. -- Version-2 project rows record their writer and monotonically advancing - revision. Version-1 rows remain readable but have no trustworthy per-record - writer and therefore use conservative conflict review. +- Version-3 project rows record their writer, monotonically advancing revision, + exact base revision, per-field writer/revision/timestamp provenance, and safe + resolution evidence. Version-1 rows remain readable but have no trustworthy + per-record writer; version-2 rows lack a portable base and field provenance. + Both therefore use conservative conflict review until imported. - Process-local semaphores serialize one VaultSync process only and do not protect a repository from another machine. -## Repository coordination — Planned for 1.8.7 +## Repository coordination — Implemented for 1.8.7 The coordination database, durable installation identity, protection of all -existing metadata writers, and version-2 per-project writer/revision fields are -implemented on the 1.8.7 release branch. Durable local merge bases and the -field-level three-way planner are also implemented. The remaining merge work will -add, with explicit migrations and fixtures: - -- base revision and field-level provenance beyond the current project writer, - revision, and timestamp; -- field-level portability and provenance for project settings; -- repository-portable resolution and bounded undo records beyond the current - durable local resolution cache; -- per-record linkage to the repository-scoped writer identity where required. +existing metadata writers, durable local merge bases, and the field-level +three-way planner are implemented on the 1.8.7 release branch. Schema version 3 +adds guarded compare-and-swap project writes, explicit base revisions, +per-field writer/revision/timestamp provenance, and the latest safe resolution +record. The remaining merge work is the bounded pre-next-write undo surface and +final two-machine qualification. The separate coordination database currently records one active lease with owner, diagnostic host label, process, operation, nonce, application version, diff --git a/src/VaultSync.Core/Config/AppConfig.cs b/src/VaultSync.Core/Config/AppConfig.cs index 4a06403d..7dd58658 100644 --- a/src/VaultSync.Core/Config/AppConfig.cs +++ b/src/VaultSync.Core/Config/AppConfig.cs @@ -432,6 +432,14 @@ public sealed class ProjectMetadataMergeBaseRecord public string WriterMachineId { get; set; } = string.Empty; public string UpdatedUtc { get; set; } = string.Empty; public ProjectMetadataConflictValues Values { get; set; } = new(); + public Dictionary FieldProvenance { get; set; } = new(StringComparer.Ordinal); + } + + public sealed class ProjectMetadataFieldProvenance + { + public string WriterMachineId { get; set; } = string.Empty; + public long Revision { get; set; } + public string UpdatedUtc { get; set; } = string.Empty; } public sealed class UpdateCheckDiagnostics diff --git a/src/VaultSync.Core/Config/AppConfigStore.cs b/src/VaultSync.Core/Config/AppConfigStore.cs index fea1450e..babf1fb4 100644 --- a/src/VaultSync.Core/Config/AppConfigStore.cs +++ b/src/VaultSync.Core/Config/AppConfigStore.cs @@ -453,7 +453,17 @@ private static void PreserveMetadataMergeBases(AppConfig config) VerificationPolicy = item.Values.VerificationPolicy, AutoBackupEnabled = item.Values.AutoBackupEnabled, Tags = item.Values.Tags - } + }, + FieldProvenance = (item.FieldProvenance ?? new Dictionary()) + .ToDictionary( + pair => pair.Key, + pair => new ProjectMetadataFieldProvenance + { + WriterMachineId = pair.Value.WriterMachineId, + Revision = pair.Value.Revision, + UpdatedUtc = pair.Value.UpdatedUtc + }, + StringComparer.Ordinal) }; private static AppConfig? TryLoadPersistedConfigForPreservation(string path) diff --git a/src/VaultSync.Core/Services/MetadataStore.cs b/src/VaultSync.Core/Services/MetadataStore.cs index 456974c2..622c3e57 100644 --- a/src/VaultSync.Core/Services/MetadataStore.cs +++ b/src/VaultSync.Core/Services/MetadataStore.cs @@ -12,7 +12,7 @@ namespace VaultSync.Core.Services; public sealed class MetadataStore { - public const int CurrentSchemaVersion = 2; + public const int CurrentSchemaVersion = 3; private const string BackupsTable = "backups"; private const string ProjectsTable = "projects"; private const string SnapshotsTable = "snapshots"; @@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS projects( settings_json TEXT NOT NULL, updated_utc TEXT NOT NULL, writer_machine_id TEXT NOT NULL DEFAULT '', - revision INTEGER NOT NULL DEFAULT 1 + revision INTEGER NOT NULL DEFAULT 1, + base_revision INTEGER NOT NULL DEFAULT 0, + field_provenance_json TEXT NOT NULL DEFAULT '{}', + resolution_json TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS snapshots( @@ -126,6 +129,9 @@ PRIMARY KEY(entity_type, entity_id) EnsureColumn(c, BackupsTable, "backup_mode", "ALTER TABLE backups ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'full';"); EnsureColumn(c, ProjectsTable, "writer_machine_id", "ALTER TABLE projects ADD COLUMN writer_machine_id TEXT NOT NULL DEFAULT '';"); EnsureColumn(c, ProjectsTable, "revision", "ALTER TABLE projects ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;"); + EnsureColumn(c, ProjectsTable, "base_revision", "ALTER TABLE projects ADD COLUMN base_revision INTEGER NOT NULL DEFAULT 0;"); + EnsureColumn(c, ProjectsTable, "field_provenance_json", "ALTER TABLE projects ADD COLUMN field_provenance_json TEXT NOT NULL DEFAULT '{}';"); + EnsureColumn(c, ProjectsTable, "resolution_json", "ALTER TABLE projects ADD COLUMN resolution_json TEXT NOT NULL DEFAULT '';"); EnsureColumn(c, SnapshotsTable, "diff_added", "ALTER TABLE snapshots ADD COLUMN diff_added INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(c, SnapshotsTable, "diff_modified", "ALTER TABLE snapshots ADD COLUMN diff_modified INTEGER NOT NULL DEFAULT 0;"); EnsureColumn(c, SnapshotsTable, "diff_deleted", "ALTER TABLE snapshots ADD COLUMN diff_deleted INTEGER NOT NULL DEFAULT 0;"); @@ -200,8 +206,8 @@ public void UpsertProject(MetaProject project) { ExecuteWrite( """ - INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision) - VALUES(@ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @Revision) + INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision, base_revision, field_provenance_json, resolution_json) + VALUES(@ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @Revision, @BaseRevision, @FieldProvenanceJson, @ResolutionJson) ON CONFLICT(external_id) DO UPDATE SET name = excluded.name, preset = excluded.preset, @@ -209,6 +215,9 @@ ON CONFLICT(external_id) DO UPDATE SET settings_json = excluded.settings_json, updated_utc = excluded.updated_utc, writer_machine_id = excluded.writer_machine_id, + base_revision = projects.revision, + field_provenance_json = excluded.field_provenance_json, + resolution_json = excluded.resolution_json, revision = CASE WHEN excluded.revision > projects.revision THEN excluded.revision ELSE projects.revision + 1 @@ -224,7 +233,10 @@ ELSE projects.revision + 1 project.SettingsJson, UpdatedUtc = ToUtcString(project.UpdatedUtc), project.WriterMachineId, - Revision = Math.Max(1, project.Revision) + Revision = Math.Max(1, project.Revision), + BaseRevision = Math.Max(0, project.BaseRevision), + project.FieldProvenanceJson, + project.ResolutionJson }); } @@ -234,8 +246,8 @@ public bool TryUpsertProject(MetaProject project, long expectedRevision) throw new ArgumentOutOfRangeException(nameof(expectedRevision)); const string sql = """ - INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision) - SELECT @ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @NextRevision + INSERT INTO projects(external_id, name, preset, root_path_hint, created_utc, settings_json, updated_utc, writer_machine_id, revision, base_revision, field_provenance_json, resolution_json) + SELECT @ExternalId, @Name, @Preset, @RootPathHint, @CreatedUtc, @SettingsJson, @UpdatedUtc, @WriterMachineId, @NextRevision, @ExpectedRevision, @FieldProvenanceJson, @ResolutionJson WHERE @ExpectedRevision = 0 OR EXISTS( SELECT 1 @@ -249,7 +261,10 @@ ON CONFLICT(external_id) DO UPDATE SET settings_json = excluded.settings_json, updated_utc = excluded.updated_utc, writer_machine_id = excluded.writer_machine_id, - revision = excluded.revision + revision = excluded.revision, + base_revision = excluded.base_revision, + field_provenance_json = excluded.field_provenance_json, + resolution_json = excluded.resolution_json WHERE projects.revision = @ExpectedRevision; """; var parameters = new @@ -262,6 +277,8 @@ ON CONFLICT(external_id) DO UPDATE SET project.SettingsJson, UpdatedUtc = ToUtcString(project.UpdatedUtc), project.WriterMachineId, + project.FieldProvenanceJson, + project.ResolutionJson, ExpectedRevision = expectedRevision, NextRevision = checked(expectedRevision + 1) }; @@ -413,6 +430,15 @@ public IEnumerable ListProjects() string revisionProjection = columns.Contains("revision") ? "revision as Revision" : "0 as Revision"; + string baseRevisionProjection = columns.Contains("base_revision") + ? "base_revision as BaseRevision" + : "0 as BaseRevision"; + string provenanceProjection = columns.Contains("field_provenance_json") + ? "field_provenance_json as FieldProvenanceJson" + : "'{}' as FieldProvenanceJson"; + string resolutionProjection = columns.Contains("resolution_json") + ? "resolution_json as ResolutionJson" + : "'' as ResolutionJson"; string sql = $""" SELECT external_id as ExternalId, @@ -423,7 +449,10 @@ public IEnumerable ListProjects() settings_json as SettingsJson, updated_utc as UpdatedUtc, {writerProjection}, - {revisionProjection} + {revisionProjection}, + {baseRevisionProjection}, + {provenanceProjection}, + {resolutionProjection} FROM projects; """; return SafeQuery(c, sql); @@ -881,6 +910,9 @@ public sealed class MetaProject public DateTime UpdatedUtc { get; set; } public string WriterMachineId { get; set; } = string.Empty; public long Revision { get; set; } + public long BaseRevision { get; set; } + public string FieldProvenanceJson { get; set; } = "{}"; + public string ResolutionJson { get; set; } = string.Empty; } public sealed class MetaSnapshot diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index fd3ac476..7c537f43 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -3433,6 +3433,7 @@ private bool TryPrepareGuardedProjectWrite( } ProjectMetadataConflictValues values = BuildExportedProjectValues(project, config); + long nextRevision = checked(expectedRevision + 1); var record = new MetaProject { ExternalId = projectExternalId, @@ -3443,7 +3444,10 @@ private bool TryPrepareGuardedProjectWrite( SettingsJson = SerializeProjectSettings(values), UpdatedUtc = updatedUtc, WriterMachineId = machineId, - Revision = checked(expectedRevision + 1) + Revision = nextRevision, + BaseRevision = expectedRevision, + FieldProvenanceJson = BuildFieldProvenanceJson(existing, values, machineId, nextRevision, updatedUtc), + ResolutionJson = BuildResolutionJson(config, sourceKey, projectExternalId) }; write = new GuardedProjectWrite(record, expectedRevision, values); return true; @@ -3511,6 +3515,108 @@ private static string SerializeProjectSettings(ProjectMetadataConflictValues val return JsonSerializer.Serialize(settings); } + private string BuildFieldProvenanceJson( + MetaProject? existing, + ProjectMetadataConflictValues values, + string writerMachineId, + long nextRevision, + DateTime updatedUtc) + { + Dictionary provenance = ParseFieldProvenance(existing?.FieldProvenanceJson); + ProjectMetadataConflictValues? previous = existing is null + ? null + : ValuesFromParsedSettings(ParseProjectSettings(existing.SettingsJson)); + string timestamp = updatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + foreach (string field in ProjectMetadataFieldNames) + { + bool changed = previous is null || !ProjectMetadataFieldEquals(field, previous, values); + if (!changed && provenance.ContainsKey(field)) + continue; + + provenance[field] = new ProjectMetadataFieldProvenance + { + WriterMachineId = changed + ? writerMachineId + : existing?.WriterMachineId ?? writerMachineId, + Revision = changed + ? nextRevision + : Math.Max(0, existing?.Revision ?? nextRevision), + UpdatedUtc = changed + ? timestamp + : existing?.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? timestamp + }; + } + + return JsonSerializer.Serialize(provenance); + } + + private static string BuildResolutionJson(AppConfig config, string sourceKey, string projectExternalId) + { + ProjectMetadataResolutionRecord? resolution = (config.Advanced.ProjectMetadataResolutions ?? []) + .Where(item => + string.Equals(item.SourceKey, sourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, projectExternalId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.ResolvedUtc, StringComparer.Ordinal) + .FirstOrDefault(); + return resolution is null ? string.Empty : JsonSerializer.Serialize(resolution); + } + + private static Dictionary ParseFieldProvenance(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new Dictionary(StringComparer.Ordinal); + + try + { + Dictionary? parsed = + JsonSerializer.Deserialize>(json); + return parsed is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); + } + catch (JsonException) + { + return new Dictionary(StringComparer.Ordinal); + } + } + + private static readonly string[] ProjectMetadataFieldNames = + [ + "avatarColor", + "encryptionPolicy", + "preferredDestinationId", + "restoreMode", + "verificationPolicy", + "autoBackupEnabled", + "tags" + ]; + + private static ProjectMetadataConflictValues ValuesFromParsedSettings(ParsedProjectSettings parsed) => new() + { + AvatarColor = parsed.HasAvatarColor ? parsed.AvatarColor : string.Empty, + EncryptionPolicy = parsed.HasEncryptionPolicy ? parsed.EncryptionPolicy : string.Empty, + PreferredDestinationId = parsed.HasPreferredDestinationId ? parsed.PreferredDestinationId : string.Empty, + RestoreMode = parsed.HasRestoreMode ? parsed.RestoreMode : string.Empty, + VerificationPolicy = parsed.HasVerificationPolicy ? parsed.VerificationPolicy : string.Empty, + AutoBackupEnabled = parsed.HasAutoBackupEnabled ? parsed.AutoBackupEnabled : null, + Tags = parsed.HasTags ? parsed.Tags : string.Empty + }; + + private static bool ProjectMetadataFieldEquals( + string field, + ProjectMetadataConflictValues left, + ProjectMetadataConflictValues right) => field switch + { + "avatarColor" => string.Equals(left.AvatarColor, right.AvatarColor, StringComparison.OrdinalIgnoreCase), + "encryptionPolicy" => string.Equals(left.EncryptionPolicy, right.EncryptionPolicy, StringComparison.OrdinalIgnoreCase), + "preferredDestinationId" => string.Equals(left.PreferredDestinationId, right.PreferredDestinationId, StringComparison.OrdinalIgnoreCase), + "restoreMode" => string.Equals(left.RestoreMode, right.RestoreMode, StringComparison.OrdinalIgnoreCase), + "verificationPolicy" => string.Equals(left.VerificationPolicy, right.VerificationPolicy, StringComparison.OrdinalIgnoreCase), + "autoBackupEnabled" => left.AutoBackupEnabled == right.AutoBackupEnabled, + "tags" => string.Equals(left.Tags, right.Tags, StringComparison.Ordinal), + _ => false + }; + private readonly record struct ParsedProjectSettings( string AvatarColor, string EncryptionPolicy, @@ -3823,11 +3929,13 @@ private static bool UpsertProjectMetadataMergeBase( string updatedUtc = project.UpdatedUtc == default ? string.Empty : project.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + Dictionary fieldProvenance = ParseFieldProvenance(project.FieldProvenanceJson); if (existing is not null && existing.Revision == project.Revision && string.Equals(existing.WriterMachineId, writerMachineId, StringComparison.Ordinal) && string.Equals(existing.UpdatedUtc, updatedUtc, StringComparison.Ordinal) && - ProjectMetadataConflictValuesEqual(existing.Values, values)) + ProjectMetadataConflictValuesEqual(existing.Values, values) && + ProjectMetadataFieldProvenanceEqual(existing.FieldProvenance, fieldProvenance)) { return false; } @@ -3843,9 +3951,24 @@ private static bool UpsertProjectMetadataMergeBase( existing.WriterMachineId = writerMachineId ?? string.Empty; existing.UpdatedUtc = updatedUtc; existing.Values = values; + existing.FieldProvenance = fieldProvenance; return true; } + private static bool ProjectMetadataFieldProvenanceEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) + { + left ??= new Dictionary(); + right ??= new Dictionary(); + if (left.Count != right.Count) + return false; + return left.All(pair => right.TryGetValue(pair.Key, out ProjectMetadataFieldProvenance? value) && + string.Equals(pair.Value.WriterMachineId, value.WriterMachineId, StringComparison.Ordinal) && + pair.Value.Revision == value.Revision && + string.Equals(pair.Value.UpdatedUtc, value.UpdatedUtc, StringComparison.Ordinal)); + } + private static bool HasDurableKeepLocalResolution( AppConfig config, MetaProject project, diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs index f8ad0fdd..5da9dbf2 100644 --- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs +++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs @@ -1645,12 +1645,42 @@ public void ExportProjectToStore_RejectsRevisionThatChangedAfterTheLocalBase() MetadataStore store = new(metaRoot); MetaProject firstRecord = Assert.IsType(store.GetProject("project-guarded-export")); Assert.Equal(1, firstRecord.Revision); + Assert.Equal(0, firstRecord.BaseRevision); + Dictionary firstProvenance = + Assert.IsType>( + JsonSerializer.Deserialize>(firstRecord.FieldProvenanceJson)); + Assert.All(firstProvenance.Values, value => + { + Assert.Equal("machine-local", value.WriterMachineId); + Assert.Equal(1, value.Revision); + }); + + AppConfig config = AppConfigStore.Load(); + ProjectMetadataMergeBaseRecord savedBase = Assert.Single(config.Advanced.ProjectMetadataMergeBases); + config.Advanced.ProjectMetadataResolutions.Add(new ProjectMetadataResolutionRecord + { + SourceKey = savedBase.SourceKey, + ProjectExternalId = "project-guarded-export", + SourceMachineId = "machine-local", + SourceRevision = 1, + Decision = "keep-local", + ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + Result = savedBase.Values + }); + AppConfigStore.Save(config); repo.UpdateProjectTags(projectId, "local-two"); MetadataSyncResult second = service.ExportProjectToStore(metaRoot, projectId, "1.8.7", "machine-local"); Assert.Equal(MetadataSyncStatus.Success, second.Status); MetaProject secondRecord = Assert.IsType(store.GetProject("project-guarded-export")); Assert.Equal(2, secondRecord.Revision); + Assert.Equal(1, secondRecord.BaseRevision); + Dictionary secondProvenance = + Assert.IsType>( + JsonSerializer.Deserialize>(secondRecord.FieldProvenanceJson)); + Assert.Equal(2, secondProvenance["tags"].Revision); + Assert.Equal(1, secondProvenance["restoreMode"].Revision); + Assert.Contains("keep-local", secondRecord.ResolutionJson, StringComparison.Ordinal); secondRecord.SettingsJson = "{\"tags\":\"remote-three\"}"; secondRecord.WriterMachineId = "machine-remote"; @@ -2175,13 +2205,23 @@ public void ImportFromStore_ProjectSettings_MergesIndependentChangesFromRecorded SettingsJson = "{\"encryptionPolicy\":\"inherit\",\"preferredDestinationId\":\"\",\"restoreMode\":\"direct\",\"verificationPolicy\":\"always\",\"autoBackupEnabled\":true,\"tags\":\"base\"}", UpdatedUtc = now, WriterMachineId = "machine-remote", - Revision = 1 + Revision = 1, + FieldProvenanceJson = JsonSerializer.Serialize(new Dictionary + { + ["tags"] = new() + { + WriterMachineId = "machine-remote", + Revision = 1, + UpdatedUtc = now.ToString("O", CultureInfo.InvariantCulture) + } + }) }; store.UpsertProject(remote); var service = new MetadataSyncService(repo); Assert.Equal(MetadataSyncStatus.Success, service.ImportFromStore(metaRoot, MetadataSyncOptions.Default).Status); - Assert.Single(AppConfigStore.Load().Advanced.ProjectMetadataMergeBases); + ProjectMetadataMergeBaseRecord importedBase = Assert.Single(AppConfigStore.Load().Advanced.ProjectMetadataMergeBases); + Assert.Equal("machine-remote", importedBase.FieldProvenance["tags"].WriterMachineId); repo.UpdateProjectTags(projectId, "local"); remote.SettingsJson = "{\"encryptionPolicy\":\"inherit\",\"preferredDestinationId\":\"\",\"restoreMode\":\"sandbox\",\"verificationPolicy\":\"always\",\"autoBackupEnabled\":true,\"tags\":\"base\"}"; @@ -2859,6 +2899,23 @@ public void ImportFromStore_LegacyBackupSchemaWithoutEncryptionColumns_ImportsAs Assert.Equal(BackupCryptoDescriptor.PlainMetadataJson, backup.CryptoDescriptorJson); } + [Fact] + public void MetadataStore_EnsureSchema_MigratesLegacyProjectsToProvenanceColumns() + { + string metaRoot = CreateTempDir(); + string projectRoot = CreateTempDir(); + CreateLegacyStoreWithoutEncryptionColumns(metaRoot, projectRoot, "legacy/backup"); + var store = new MetadataStore(metaRoot); + + store.EnsureSchema(); + + MetaProject project = Assert.IsType(store.GetProject("legacy-project-1")); + Assert.Equal(1, project.Revision); + Assert.Equal(0, project.BaseRevision); + Assert.Equal("{}", project.FieldProvenanceJson); + Assert.Equal(string.Empty, project.ResolutionJson); + } + public void Dispose() { foreach (TempDirectory directory in _tempDirs.OrderByDescending(directory => directory.Path.Length)) From 41c59b3e296bc48a007d2883fbf5110659dcf352 Mon Sep 17 00:00:00 2001 From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:10 +0200 Subject: [PATCH 52/54] Review and undo metadata merge decisions --- CHANGELOG.md | 1 + Localization/strings.ar.json | 6 + Localization/strings.bn.json | 6 + Localization/strings.de.json | 6 + Localization/strings.en.json | 6 + Localization/strings.es.json | 6 + Localization/strings.fr.json | 6 + Localization/strings.hi.json | 6 + Localization/strings.id.json | 6 + Localization/strings.it.json | 6 + Localization/strings.ja.json | 6 + Localization/strings.ko.json | 6 + Localization/strings.nl.json | 6 + Localization/strings.pl.json | 6 + Localization/strings.pt.json | 6 + Localization/strings.ru.json | 6 + Localization/strings.tr.json | 6 + Localization/strings.uk.json | 6 + Localization/strings.vi.json | 6 + Localization/strings.zh.json | 6 + ROADMAP.md | 5 +- docs/CROSS_MACHINE_SAFETY.md | 9 +- docs/RELEASE_1.8.7.md | 8 +- docs/REPOSITORY_FORMATS.md | 6 +- src/VaultSync.Core/Config/AppConfig.cs | 7 + .../Services/MetadataSyncService.cs | 59 ++++- .../ViewModels/SettingsViewModel.cs | 201 +++++++++++++++++- src/VaultSync.UI/Views/SettingsView.axaml | 69 +++++- .../VaultSync.Core.Tests/MetadataSyncTests.cs | 5 + 29 files changed, 457 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb2e271..84ad594b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [VS-1877] Added repository-scoped writer leases with atomic acquisition, heartbeat and expiry, read-only busy inspection, nonce-bound release, explicit stale takeover, and retained takeover evidence. - [VS-1877] Added per-destination repository-writer inspection and an explicit stale-takeover review that shows the owner, operation, version, heartbeat, and expiry before preserving the old lease as evidence. - [VS-1879] Added durable per-source merge bases and a field-level three-way metadata planner so independent cross-machine edits merge automatically while overlapping edits remain explicitly reviewable. +- [VS-1879] Added a Base/local/remote conflict table with revision, writer, and timestamp context plus a durable Undo decision action that expires after the next portable repository write. ### Changed - [BUG-18099] Serviced the .NET 10 baseline to SDK `10.0.303`, runtime `10.0.11`, and coordinated Microsoft packages, with CI auditing real self-contained publishes and release artifacts for every supported runtime identifier. - [VS-1877] Protected project settings, backup history, tombstones, deferred metadata writes, and deferred flushing with repository lease ownership checks while keeping imports and previews readable when another writer is active. diff --git a/Localization/strings.ar.json b/Localization/strings.ar.json index 7d6422af..b2062eb5 100644 --- a/Localization/strings.ar.json +++ b/Localization/strings.ar.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "الأساس", + "Settings.Advanced.MetadataUndoNone": "لا يوجد حاليًا قرار بيانات وصفية يمكن التراجع عنه.", + "Settings.Advanced.MetadataUndoAvailable": "يمكن التراجع عن آخر قرار لـ {0} حتى عملية الكتابة التالية إلى المستودع.", + "Settings.Advanced.MetadataUndoComplete": "تمت استعادة البيانات الوصفية السابقة لـ {0}.", + "Settings.Advanced.MetadataUndoFailed": "فشل التراجع عن قرار البيانات الوصفية: {0}", + "Settings.Advanced.MetadataUndoAction": "التراجع عن القرار", "Common.Save": "حفظ", "Common.Delete": "حذف", "Projects.Folder.NewName": "اسم المجلد الجديد", diff --git a/Localization/strings.bn.json b/Localization/strings.bn.json index 0883526a..73f978a2 100644 --- a/Localization/strings.bn.json +++ b/Localization/strings.bn.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "ভিত্তি", + "Settings.Advanced.MetadataUndoNone": "বর্তমানে কোনো মেটাডেটা সিদ্ধান্ত পূর্বাবস্থায় ফেরানো যাবে না।", + "Settings.Advanced.MetadataUndoAvailable": "{0}-এর শেষ সিদ্ধান্তটি পরবর্তী রিপোজিটরি লেখার আগে পর্যন্ত পূর্বাবস্থায় ফেরানো যাবে।", + "Settings.Advanced.MetadataUndoComplete": "{0}-এর আগের মেটাডেটা পুনরুদ্ধার করা হয়েছে।", + "Settings.Advanced.MetadataUndoFailed": "মেটাডেটা সিদ্ধান্ত পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে: {0}", + "Settings.Advanced.MetadataUndoAction": "সিদ্ধান্ত পূর্বাবস্থায় ফেরান", "Common.Save": "সংরক্ষণ করুন", "Common.Delete": "মুছুন", "Projects.Folder.NewName": "নতুন ফোল্ডারের নাম", diff --git a/Localization/strings.de.json b/Localization/strings.de.json index 511dc5dc..238344b2 100644 --- a/Localization/strings.de.json +++ b/Localization/strings.de.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Basis", + "Settings.Advanced.MetadataUndoNone": "Derzeit kann keine Metadatenentscheidung rückgängig gemacht werden.", + "Settings.Advanced.MetadataUndoAvailable": "Die letzte Entscheidung für {0} kann bis zum nächsten Repository-Schreibvorgang rückgängig gemacht werden.", + "Settings.Advanced.MetadataUndoComplete": "Die vorherigen Metadaten für {0} wurden wiederhergestellt.", + "Settings.Advanced.MetadataUndoFailed": "Die Metadatenentscheidung konnte nicht rückgängig gemacht werden: {0}", + "Settings.Advanced.MetadataUndoAction": "Entscheidung rückgängig", "Common.Save": "Speichern", "Common.Delete": "Löschen", "Projects.Folder.NewName": "Neuer Ordnername", diff --git a/Localization/strings.en.json b/Localization/strings.en.json index 9eddeb77..dc62ae60 100644 --- a/Localization/strings.en.json +++ b/Localization/strings.en.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Base", + "Settings.Advanced.MetadataUndoNone": "No metadata resolution is currently undoable.", + "Settings.Advanced.MetadataUndoAvailable": "The last decision for {0} can be undone until the next repository write.", + "Settings.Advanced.MetadataUndoComplete": "Restored the previous metadata for {0}.", + "Settings.Advanced.MetadataUndoFailed": "Undoing the metadata decision failed: {0}", + "Settings.Advanced.MetadataUndoAction": "Undo decision", "Common.Save": "Save", "Common.Delete": "Delete", "Projects.Folder.NewName": "New folder name", diff --git a/Localization/strings.es.json b/Localization/strings.es.json index 5128d999..ad36e2d5 100644 --- a/Localization/strings.es.json +++ b/Localization/strings.es.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Base", + "Settings.Advanced.MetadataUndoNone": "Actualmente no se puede deshacer ninguna resolución de metadatos.", + "Settings.Advanced.MetadataUndoAvailable": "La última decisión para {0} se puede deshacer hasta la próxima escritura en el repositorio.", + "Settings.Advanced.MetadataUndoComplete": "Se restauraron los metadatos anteriores de {0}.", + "Settings.Advanced.MetadataUndoFailed": "No se pudo deshacer la decisión de metadatos: {0}", + "Settings.Advanced.MetadataUndoAction": "Deshacer decisión", "Common.Save": "Guardar", "Common.Delete": "Eliminar", "Projects.Folder.NewName": "Nuevo nombre de carpeta", diff --git a/Localization/strings.fr.json b/Localization/strings.fr.json index 1b4e2283..d57bd42e 100644 --- a/Localization/strings.fr.json +++ b/Localization/strings.fr.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Base", + "Settings.Advanced.MetadataUndoNone": "Aucune résolution de métadonnées ne peut actuellement être annulée.", + "Settings.Advanced.MetadataUndoAvailable": "La dernière décision pour {0} peut être annulée jusqu'à la prochaine écriture dans le dépôt.", + "Settings.Advanced.MetadataUndoComplete": "Les métadonnées précédentes de {0} ont été restaurées.", + "Settings.Advanced.MetadataUndoFailed": "Échec de l'annulation de la décision de métadonnées : {0}", + "Settings.Advanced.MetadataUndoAction": "Annuler la décision", "Common.Save": "Enregistrer", "Common.Delete": "Supprimer", "Projects.Folder.NewName": "Nouveau nom de dossier", diff --git a/Localization/strings.hi.json b/Localization/strings.hi.json index 9dcd56c3..9d85858d 100644 --- a/Localization/strings.hi.json +++ b/Localization/strings.hi.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "आधार", + "Settings.Advanced.MetadataUndoNone": "फ़िलहाल कोई मेटाडेटा निर्णय पूर्ववत नहीं किया जा सकता।", + "Settings.Advanced.MetadataUndoAvailable": "{0} के अंतिम निर्णय को अगली रिपॉज़िटरी लिखाई तक पूर्ववत किया जा सकता है।", + "Settings.Advanced.MetadataUndoComplete": "{0} का पिछला मेटाडेटा बहाल कर दिया गया।", + "Settings.Advanced.MetadataUndoFailed": "मेटाडेटा निर्णय पूर्ववत नहीं हो सका: {0}", + "Settings.Advanced.MetadataUndoAction": "निर्णय पूर्ववत करें", "Common.Save": "सहेजें", "Common.Delete": "हटाएँ", "Projects.Folder.NewName": "नये फ़ोल्डर का नाम", diff --git a/Localization/strings.id.json b/Localization/strings.id.json index 3d4a7cd9..2d830849 100644 --- a/Localization/strings.id.json +++ b/Localization/strings.id.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Dasar", + "Settings.Advanced.MetadataUndoNone": "Saat ini tidak ada keputusan metadata yang dapat dibatalkan.", + "Settings.Advanced.MetadataUndoAvailable": "Keputusan terakhir untuk {0} dapat dibatalkan hingga penulisan repositori berikutnya.", + "Settings.Advanced.MetadataUndoComplete": "Metadata sebelumnya untuk {0} telah dipulihkan.", + "Settings.Advanced.MetadataUndoFailed": "Gagal membatalkan keputusan metadata: {0}", + "Settings.Advanced.MetadataUndoAction": "Batalkan keputusan", "Common.Save": "Simpan", "Common.Delete": "Hapus", "Projects.Folder.NewName": "Nama folder baru", diff --git a/Localization/strings.it.json b/Localization/strings.it.json index a4d47a65..66773768 100644 --- a/Localization/strings.it.json +++ b/Localization/strings.it.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Base", + "Settings.Advanced.MetadataUndoNone": "Nessuna risoluzione dei metadati può essere annullata al momento.", + "Settings.Advanced.MetadataUndoAvailable": "L'ultima decisione per {0} può essere annullata fino alla prossima scrittura nel repository.", + "Settings.Advanced.MetadataUndoComplete": "Sono stati ripristinati i metadati precedenti per {0}.", + "Settings.Advanced.MetadataUndoFailed": "Impossibile annullare la decisione sui metadati: {0}", + "Settings.Advanced.MetadataUndoAction": "Annulla decisione", "Common.Save": "Salva", "Common.Delete": "Elimina", "Projects.Folder.NewName": "Nuovo nome della cartella", diff --git a/Localization/strings.ja.json b/Localization/strings.ja.json index 7aa834ca..a9b0de5a 100644 --- a/Localization/strings.ja.json +++ b/Localization/strings.ja.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "ベース", + "Settings.Advanced.MetadataUndoNone": "現在、元に戻せるメタデータの決定はありません。", + "Settings.Advanced.MetadataUndoAvailable": "{0} の最後の決定は、次のリポジトリ書き込みまで元に戻せます。", + "Settings.Advanced.MetadataUndoComplete": "{0} の以前のメタデータを復元しました。", + "Settings.Advanced.MetadataUndoFailed": "メタデータの決定を元に戻せませんでした: {0}", + "Settings.Advanced.MetadataUndoAction": "決定を元に戻す", "Common.Save": "保存", "Common.Delete": "削除", "Projects.Folder.NewName": "新しいフォルダー名", diff --git a/Localization/strings.ko.json b/Localization/strings.ko.json index 02f377c4..5038ec76 100644 --- a/Localization/strings.ko.json +++ b/Localization/strings.ko.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "기준", + "Settings.Advanced.MetadataUndoNone": "현재 실행 취소할 수 있는 메타데이터 결정이 없습니다.", + "Settings.Advanced.MetadataUndoAvailable": "{0}의 마지막 결정은 다음 저장소 쓰기 전까지 실행 취소할 수 있습니다.", + "Settings.Advanced.MetadataUndoComplete": "{0}의 이전 메타데이터를 복원했습니다.", + "Settings.Advanced.MetadataUndoFailed": "메타데이터 결정을 실행 취소하지 못했습니다: {0}", + "Settings.Advanced.MetadataUndoAction": "결정 실행 취소", "Common.Save": "저장", "Common.Delete": "삭제", "Projects.Folder.NewName": "새 폴더 이름", diff --git a/Localization/strings.nl.json b/Localization/strings.nl.json index cf9c8eb3..08ef07f1 100644 --- a/Localization/strings.nl.json +++ b/Localization/strings.nl.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Basis", + "Settings.Advanced.MetadataUndoNone": "Er kan momenteel geen metadatabeslissing ongedaan worden gemaakt.", + "Settings.Advanced.MetadataUndoAvailable": "De laatste beslissing voor {0} kan ongedaan worden gemaakt tot de volgende schrijfactie naar de opslagplaats.", + "Settings.Advanced.MetadataUndoComplete": "De vorige metadata voor {0} zijn hersteld.", + "Settings.Advanced.MetadataUndoFailed": "De metadatabeslissing kon niet ongedaan worden gemaakt: {0}", + "Settings.Advanced.MetadataUndoAction": "Beslissing terugdraaien", "Common.Save": "Opslaan", "Common.Delete": "Verwijderen", "Projects.Folder.NewName": "Nieuwe mapnaam", diff --git a/Localization/strings.pl.json b/Localization/strings.pl.json index a9ade7b7..08ea5a85 100644 --- a/Localization/strings.pl.json +++ b/Localization/strings.pl.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Baza", + "Settings.Advanced.MetadataUndoNone": "Obecnie nie można cofnąć żadnej decyzji dotyczącej metadanych.", + "Settings.Advanced.MetadataUndoAvailable": "Ostatnią decyzję dla {0} można cofnąć do czasu następnego zapisu w repozytorium.", + "Settings.Advanced.MetadataUndoComplete": "Przywrócono poprzednie metadane dla {0}.", + "Settings.Advanced.MetadataUndoFailed": "Nie udało się cofnąć decyzji dotyczącej metadanych: {0}", + "Settings.Advanced.MetadataUndoAction": "Cofnij decyzję", "Common.Save": "Zapisz", "Common.Delete": "Usuń", "Projects.Folder.NewName": "Nowa nazwa folderu", diff --git a/Localization/strings.pt.json b/Localization/strings.pt.json index 1f1828ab..586e6ea9 100644 --- a/Localization/strings.pt.json +++ b/Localization/strings.pt.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Base", + "Settings.Advanced.MetadataUndoNone": "Nenhuma resolução de metadados pode ser desfeita no momento.", + "Settings.Advanced.MetadataUndoAvailable": "A última decisão para {0} pode ser desfeita até à próxima gravação no repositório.", + "Settings.Advanced.MetadataUndoComplete": "Os metadados anteriores de {0} foram restaurados.", + "Settings.Advanced.MetadataUndoFailed": "Falha ao desfazer a decisão de metadados: {0}", + "Settings.Advanced.MetadataUndoAction": "Desfazer decisão", "Common.Save": "Salvar", "Common.Delete": "Excluir", "Projects.Folder.NewName": "Novo nome de pasta", diff --git a/Localization/strings.ru.json b/Localization/strings.ru.json index edcf5629..cd81dab4 100644 --- a/Localization/strings.ru.json +++ b/Localization/strings.ru.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "База", + "Settings.Advanced.MetadataUndoNone": "Сейчас нет решения по метаданным, которое можно отменить.", + "Settings.Advanced.MetadataUndoAvailable": "Последнее решение для {0} можно отменить до следующей записи в репозиторий.", + "Settings.Advanced.MetadataUndoComplete": "Предыдущие метаданные для {0} восстановлены.", + "Settings.Advanced.MetadataUndoFailed": "Не удалось отменить решение по метаданным: {0}", + "Settings.Advanced.MetadataUndoAction": "Отменить решение", "Common.Save": "Сохранить", "Common.Delete": "Удалить", "Projects.Folder.NewName": "Новое имя папки", diff --git a/Localization/strings.tr.json b/Localization/strings.tr.json index 031f7fb2..3df8d10c 100644 --- a/Localization/strings.tr.json +++ b/Localization/strings.tr.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Temel", + "Settings.Advanced.MetadataUndoNone": "Şu anda geri alınabilecek bir meta veri kararı yok.", + "Settings.Advanced.MetadataUndoAvailable": "{0} için son karar, depoya bir sonraki yazmaya kadar geri alınabilir.", + "Settings.Advanced.MetadataUndoComplete": "{0} için önceki meta veriler geri yüklendi.", + "Settings.Advanced.MetadataUndoFailed": "Meta veri kararı geri alınamadı: {0}", + "Settings.Advanced.MetadataUndoAction": "Kararı geri al", "Common.Save": "Kaydet", "Common.Delete": "Sil", "Projects.Folder.NewName": "Yeni klasör adı", diff --git a/Localization/strings.uk.json b/Localization/strings.uk.json index 2154f8c5..ef89a8c6 100644 --- a/Localization/strings.uk.json +++ b/Localization/strings.uk.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "База", + "Settings.Advanced.MetadataUndoNone": "Наразі немає рішення щодо метаданих, яке можна скасувати.", + "Settings.Advanced.MetadataUndoAvailable": "Останнє рішення для {0} можна скасувати до наступного запису в репозиторій.", + "Settings.Advanced.MetadataUndoComplete": "Попередні метадані для {0} відновлено.", + "Settings.Advanced.MetadataUndoFailed": "Не вдалося скасувати рішення щодо метаданих: {0}", + "Settings.Advanced.MetadataUndoAction": "Скасувати рішення", "Common.Save": "зберегти", "Common.Delete": "Видалити", "Projects.Folder.NewName": "Нова назва папки", diff --git a/Localization/strings.vi.json b/Localization/strings.vi.json index 414ec68c..ae982040 100644 --- a/Localization/strings.vi.json +++ b/Localization/strings.vi.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "Cơ sở", + "Settings.Advanced.MetadataUndoNone": "Hiện không có quyết định siêu dữ liệu nào có thể hoàn tác.", + "Settings.Advanced.MetadataUndoAvailable": "Quyết định gần nhất cho {0} có thể được hoàn tác cho đến lần ghi kho tiếp theo.", + "Settings.Advanced.MetadataUndoComplete": "Đã khôi phục siêu dữ liệu trước đó cho {0}.", + "Settings.Advanced.MetadataUndoFailed": "Không thể hoàn tác quyết định siêu dữ liệu: {0}", + "Settings.Advanced.MetadataUndoAction": "Hoàn tác quyết định", "Common.Save": "Lưu", "Common.Delete": "Xóa", "Projects.Folder.NewName": "Tên thư mục mới", diff --git a/Localization/strings.zh.json b/Localization/strings.zh.json index 6d9459e5..78a89f4f 100644 --- a/Localization/strings.zh.json +++ b/Localization/strings.zh.json @@ -1,4 +1,10 @@ { + "Settings.Advanced.MetadataConflictsBaseLabel": "基准", + "Settings.Advanced.MetadataUndoNone": "当前没有可撤销的元数据决定。", + "Settings.Advanced.MetadataUndoAvailable": "在下次写入存储库之前,可以撤销对 {0} 的最后决定。", + "Settings.Advanced.MetadataUndoComplete": "已恢复 {0} 之前的元数据。", + "Settings.Advanced.MetadataUndoFailed": "撤销元数据决定失败:{0}", + "Settings.Advanced.MetadataUndoAction": "撤销决定", "Common.Save": "保存", "Common.Delete": "删除", "Projects.Folder.NewName": "新文件夹名称", diff --git a/ROADMAP.md b/ROADMAP.md index bc50cd9d..cf810290 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -472,8 +472,9 @@ after the planned minor train is complete and use explicit beta qualification. field-level three-way planning, automatic non-overlapping merges, and resolution results that retain independent edits are implemented and tested. Guarded repository writes and schema-version-3 base/provenance and - resolution export followed on 2026-08-17. Base/local/remote presentation, - bounded undo, and final two-machine qualification remain before completion. + resolution export followed on 2026-08-17, together with Base/local/remote + presentation and bounded undo that expires after the next portable write. + Final two-machine qualification remains before completion. - [ ] `VS-1880` `P1` Simplify and standardize shared application code without changing user-visible behavior. - Scope: consolidate repeated retry, path, serialization, status, dialog, diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md index e7c44444..68bd226e 100644 --- a/docs/CROSS_MACHINE_SAFETY.md +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -150,12 +150,13 @@ revision. **Version-2 project writer/revision columns and version-1 compatibility were implemented on 2026-08-16. Durable per-source base revisions and field-level three-way planning were implemented on 2026-08-16. Schema-version-3 guarded - writes, base revisions, field provenance, and safe resolution export were - implemented on 2026-08-17; bounded undo remains in the VS-1879 merge work.** + writes, base revisions, field provenance, safe resolution export, explicit + Base/local/remote review, and bounded pre-next-write undo were implemented + on 2026-08-17.** 5. Produce merge plans without applying them. 6. Add explicit apply, durable resolution, and bounded undo. - **Durable Keep local and Accept imported decisions are implemented and - bounded; revision-aware undo remains in VS-1879.** + **Durable Keep local and Accept imported decisions plus revision-aware undo + until the next portable repository write are implemented.** 7. Expose status, takeover, and conflict review in the UI. **Writer status and explicit stale takeover were implemented on 2026-08-16; complete portable-field conflict review was implemented on 2026-08-16.** diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md index 66891cbe..0d58427e 100644 --- a/docs/RELEASE_1.8.7.md +++ b/docs/RELEASE_1.8.7.md @@ -114,6 +114,10 @@ complete and stable enough for broader qualification. inspected. Schema-version-3 rows carry their base revision, per-field writer, revision and timestamp provenance, plus the latest safe resolution evidence; stale writes roll back without replacing remote metadata (`VS-1879`). +- Conflict review now presents Base/local/remote values with revision, writer, + and timestamp context. The latest decision can restore the previous local + state until the next portable repository write supersedes that undo record; + all six undo strings ship in every maintained locale (`VS-1879`). These changes are not shipped until the release work reaches `Stable`. Dependabot can therefore continue to report the old default-branch runtime @@ -122,8 +126,8 @@ branch package. ### In progress next -1. Finish Base/local/remote conflict presentation and bounded undo for resolved - metadata merges. +1. Complete the two-machine, disconnect, clock-skew, and NAS/SMB qualification + matrix for versioned metadata merging. 2. Expose the running build and canonical release identity through the app, CLI, diagnostics, and support exports. 3. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB diff --git a/docs/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md index b39ac5dd..c68b7407 100644 --- a/docs/REPOSITORY_FORMATS.md +++ b/docs/REPOSITORY_FORMATS.md @@ -67,8 +67,10 @@ existing metadata writers, durable local merge bases, and the field-level three-way planner are implemented on the 1.8.7 release branch. Schema version 3 adds guarded compare-and-swap project writes, explicit base revisions, per-field writer/revision/timestamp provenance, and the latest safe resolution -record. The remaining merge work is the bounded pre-next-write undo surface and -final two-machine qualification. +record. Conflict review exposes Base/local/remote values with revision, writer, +and timestamp context. Resolution records retain the previous local state and +remain undoable until the next portable repository write marks them superseded. +Final two-machine qualification remains a release gate. The separate coordination database currently records one active lease with owner, diagnostic host label, process, operation, nonce, application version, diff --git a/src/VaultSync.Core/Config/AppConfig.cs b/src/VaultSync.Core/Config/AppConfig.cs index 7dd58658..6ac54e17 100644 --- a/src/VaultSync.Core/Config/AppConfig.cs +++ b/src/VaultSync.Core/Config/AppConfig.cs @@ -387,6 +387,10 @@ public sealed class ProjectMetadataConflictRecord public string ProjectName { get; set; } = string.Empty; public string SourceMachineId { get; set; } = string.Empty; public string SourceUpdatedUtc { get; set; } = string.Empty; + public string BaseMachineId { get; set; } = string.Empty; + public string BaseUpdatedUtc { get; set; } = string.Empty; + public string LocalMachineId { get; set; } = string.Empty; + public string DetectedUtc { get; set; } = string.Empty; public string SourceKey { get; set; } = string.Empty; public long SourceRevision { get; set; } public long BaseRevision { get; set; } @@ -419,6 +423,9 @@ public sealed class ProjectMetadataResolutionRecord public long BaseRevision { get; set; } public string Decision { get; set; } = string.Empty; public string ResolvedUtc { get; set; } = string.Empty; + public bool UndoAvailable { get; set; } + public string UndoneUtc { get; set; } = string.Empty; + public string SupersededUtc { get; set; } = string.Empty; public ProjectMetadataConflictValues Local { get; set; } = new(); public ProjectMetadataConflictValues Imported { get; set; } = new(); public ProjectMetadataConflictValues Result { get; set; } = new(); diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs index 7c537f43..9c073a56 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -95,6 +95,10 @@ private sealed record ProjectMetadataConflictContext( string SourceKey, string? SourceMachineId, long BaseRevision, + string BaseMachineId, + string BaseUpdatedUtc, + string LocalMachineId, + string DetectedUtc, ProjectMetadataConflictValues Base, ProjectMetadataConflictValues Local, ProjectMetadataConflictValues Incoming, @@ -3467,15 +3471,26 @@ private void SaveSuccessfulProjectWriteBase(string destinationRoot, GuardedProje AppConfig config = _configStore.Load(); string sourceKey = BuildMetadataSourceKey( Path.GetFullPath(destinationRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - if (UpsertProjectMetadataMergeBase( + bool changed = false; + string supersededUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + foreach (ProjectMetadataResolutionRecord resolution in (config.Advanced.ProjectMetadataResolutions ?? []) + .Where(item => item.UndoAvailable && + string.Equals(item.SourceKey, sourceKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.ProjectExternalId, write.Record.ExternalId, StringComparison.OrdinalIgnoreCase))) + { + resolution.UndoAvailable = false; + resolution.SupersededUtc = supersededUtc; + changed = true; + } + + changed |= UpsertProjectMetadataMergeBase( config, sourceKey, write.Record, write.Record.WriterMachineId, - write.Values)) - { + write.Values); + if (changed) _configStore.Save(config); - } } catch (Exception ex) { @@ -3871,6 +3886,10 @@ private bool ApplyImportedProjectSettings( sourceKey, sourceMachineId, mergeBase?.Revision ?? 0, + mergeBase?.WriterMachineId ?? string.Empty, + mergeBase?.UpdatedUtc ?? string.Empty, + ResolveLocalMetadataWriterId(), + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), trustedBase ?? new ProjectMetadataConflictValues(), localValues, incomingValues, @@ -3878,6 +3897,18 @@ private bool ApplyImportedProjectSettings( pendingConflicts); } + private string ResolveLocalMetadataWriterId() + { + try + { + return _installationIdentityProvider?.GetOrCreate() ?? "this-installation"; + } + catch + { + return "this-installation"; + } + } + private void ApplyProjectMetadataValues( AppConfig config, Project current, @@ -3983,6 +4014,7 @@ private static bool HasDurableKeepLocalResolution( : sourceMachineId; return (config.Advanced.ProjectMetadataResolutions ?? []).Any(resolution => string.Equals(resolution.Decision, "keep-local", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(resolution.UndoneUtc) && string.Equals(resolution.ProjectExternalId, project.ExternalId, StringComparison.OrdinalIgnoreCase) && string.Equals(resolution.SourceMachineId, normalizedSourceMachineId, StringComparison.Ordinal) && string.Equals(resolution.SourceUpdatedUtc, sourceUpdatedUtc, StringComparison.Ordinal) && @@ -4032,6 +4064,10 @@ private static bool UpsertProjectMetadataConflict( SourceKey = context.SourceKey, SourceRevision = metaProject.Revision, BaseRevision = context.BaseRevision, + BaseMachineId = context.BaseMachineId, + BaseUpdatedUtc = context.BaseUpdatedUtc, + LocalMachineId = context.LocalMachineId, + DetectedUtc = context.DetectedUtc, SourceUpdatedUtc = metaProject.UpdatedUtc == default ? string.Empty : metaProject.UpdatedUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), @@ -4054,6 +4090,13 @@ private static bool UpsertProjectMetadataConflict( return true; } + if (string.Equals(existing.SourceKey, next.SourceKey, StringComparison.OrdinalIgnoreCase) && + existing.SourceRevision == next.SourceRevision && + !string.IsNullOrWhiteSpace(existing.DetectedUtc)) + { + next.DetectedUtc = existing.DetectedUtc; + } + if (ProjectMetadataConflictEquals(existing, next)) return false; @@ -4065,6 +4108,10 @@ private static bool UpsertProjectMetadataConflict( existing.SourceKey = next.SourceKey; existing.SourceRevision = next.SourceRevision; existing.BaseRevision = next.BaseRevision; + existing.BaseMachineId = next.BaseMachineId; + existing.BaseUpdatedUtc = next.BaseUpdatedUtc; + existing.LocalMachineId = next.LocalMachineId; + existing.DetectedUtc = next.DetectedUtc; existing.ConflictingFields = next.ConflictingFields; existing.Base = next.Base; existing.Local = next.Local; @@ -4084,6 +4131,10 @@ private static bool ProjectMetadataConflictEquals(ProjectMetadataConflictRecord string.Equals(left.SourceKey, right.SourceKey, StringComparison.OrdinalIgnoreCase) && left.SourceRevision == right.SourceRevision && left.BaseRevision == right.BaseRevision && + string.Equals(left.BaseMachineId, right.BaseMachineId, StringComparison.Ordinal) && + string.Equals(left.BaseUpdatedUtc, right.BaseUpdatedUtc, StringComparison.Ordinal) && + string.Equals(left.LocalMachineId, right.LocalMachineId, StringComparison.Ordinal) && + string.Equals(left.DetectedUtc, right.DetectedUtc, StringComparison.Ordinal) && left.ConflictingFields.SequenceEqual(right.ConflictingFields, StringComparer.Ordinal) && ProjectMetadataConflictValuesEqual(left.Base, right.Base) && ProjectMetadataConflictValuesEqual(left.Local, right.Local) && diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs index 917801b9..d8cb65f6 100644 --- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs +++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs @@ -169,12 +169,15 @@ public sealed partial class SettingsViewModel : ViewModelBase private readonly RelayCommand? _applyBackupIndexRepairPlanCommand; private readonly RelayCommand? _acceptProjectMetadataConflictCommand; private readonly RelayCommand? _keepLocalProjectMetadataConflictCommand; + private readonly RelayCommand? _undoProjectMetadataResolutionCommand; private readonly RelayCommand? _runRetentionSimulationCommand; private BackupIndexRepairPlan? _currentBackupIndexRepairPlan; private string _backupIndexRepairStatus = string.Empty; private string _backupIndexRepairSummary = string.Empty; private string _backupIndexRepairDetails = string.Empty; private string _projectMetadataConflictStatus = string.Empty; + private string _projectMetadataUndoStatus = string.Empty; + private bool _hasUndoableProjectMetadataResolution; private bool _isBackupIndexRepairBusy; private bool _showLegacyBackupLocation = true; private string _customThemeName = "VaultSync Midnight"; @@ -235,18 +238,28 @@ public sealed class ProjectMetadataConflictItemViewModel public required string ProjectExternalId { get; init; } public required string SourceMachineId { get; init; } public required string SourceUpdatedUtc { get; init; } + public required string RevisionSummary { get; init; } + public required string ProvenanceSummary { get; init; } + public required string ConflictingFields { get; init; } + public required string BaseAvatarColor { get; init; } public required string LocalAvatarColor { get; init; } public required string ImportedAvatarColor { get; init; } + public required string BaseEncryptionPolicy { get; init; } public required string LocalEncryptionPolicy { get; init; } public required string ImportedEncryptionPolicy { get; init; } + public required string BasePreferredDestinationId { get; init; } public required string LocalPreferredDestinationId { get; init; } public required string ImportedPreferredDestinationId { get; init; } + public required string BaseRestoreMode { get; init; } public required string LocalRestoreMode { get; init; } public required string ImportedRestoreMode { get; init; } + public required string BaseVerificationPolicy { get; init; } public required string LocalVerificationPolicy { get; init; } public required string ImportedVerificationPolicy { get; init; } + public required string BaseAutoBackupEnabled { get; init; } public required string LocalAutoBackupEnabled { get; init; } public required string ImportedAutoBackupEnabled { get; init; } + public required string BaseTags { get; init; } public required string LocalTags { get; init; } public required string ImportedTags { get; init; } } @@ -674,6 +687,9 @@ public SettingsViewModel( _keepLocalProjectMetadataConflictCommand = new RelayCommand( parameter => KeepLocalProjectMetadataConflict(parameter as ProjectMetadataConflictItemViewModel), parameter => parameter is ProjectMetadataConflictItemViewModel && !IsBackupIndexRepairBusy); + _undoProjectMetadataResolutionCommand = new RelayCommand( + _ => UndoProjectMetadataResolution(), + _ => HasUndoableProjectMetadataResolution && !IsBackupIndexRepairBusy); _runRetentionSimulationCommand = new RelayCommand(_ => RunRetentionSimulation(), _ => !IsRetentionSimulationBusy); RefreshHistoryCommand = new RelayCommand(_ => RefreshHistoryRequested?.Invoke()); SetBackupEncryptionPasswordCommand = new RelayCommand(_ => SetBackupEncryptionPassword()); @@ -813,6 +829,7 @@ private void LoadFromConfig() RefreshStartupDiagnostics(cfg.Advanced.StartupDiagnostics); RefreshCheckpointResumeDiagnostics(cfg.Advanced.CheckpointResumeTelemetry); RefreshProjectMetadataConflicts(cfg.Advanced.ProjectMetadataConflicts); + RefreshProjectMetadataUndo(cfg.Advanced.ProjectMetadataResolutions); // Apply theme + layout when loading config (in case Settings view is opened first) ApplyThemeFromSelected(); @@ -2075,6 +2092,23 @@ public string ProjectMetadataConflictStatus private set => SetField(ref _projectMetadataConflictStatus, value); } + public string ProjectMetadataUndoStatus + { + get => _projectMetadataUndoStatus; + private set => SetField(ref _projectMetadataUndoStatus, value); + } + + public bool HasUndoableProjectMetadataResolution + { + get => _hasUndoableProjectMetadataResolution; + private set + { + if (!SetField(ref _hasUndoableProjectMetadataResolution, value)) + return; + _undoProjectMetadataResolutionCommand?.RaiseCanExecuteChanged(); + } + } + public string RetentionSimulationStatus { get => _retentionSimulationStatus; @@ -2795,6 +2829,7 @@ private static string PrependPathEntry(string existing, string entry) public ICommand ApplyBackupIndexRepairPlanCommand => _applyBackupIndexRepairPlanCommand!; public ICommand AcceptProjectMetadataConflictCommand => _acceptProjectMetadataConflictCommand!; public ICommand KeepLocalProjectMetadataConflictCommand => _keepLocalProjectMetadataConflictCommand!; + public ICommand UndoProjectMetadataResolutionCommand => _undoProjectMetadataResolutionCommand!; public ICommand RunRetentionSimulationCommand => _runRetentionSimulationCommand!; public ICommand RefreshHistoryCommand { get; } public ICommand SetBackupEncryptionPasswordCommand { get; } @@ -3328,6 +3363,9 @@ void Raise() { _scanBackupIndexRepairPlanCommand?.RaiseCanExecuteChanged(); _applyBackupIndexRepairPlanCommand?.RaiseCanExecuteChanged(); + _acceptProjectMetadataConflictCommand?.RaiseCanExecuteChanged(); + _keepLocalProjectMetadataConflictCommand?.RaiseCanExecuteChanged(); + _undoProjectMetadataResolutionCommand?.RaiseCanExecuteChanged(); } if (Dispatcher.UIThread.CheckAccess()) @@ -4109,18 +4147,28 @@ private void RefreshProjectMetadataConflicts(IEnumerable string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + private string FormatConflictingFields(IEnumerable? fields) + => string.Join(", ", (fields ?? []) + .Select(field => field switch + { + "avatarColor" => L("Settings.Advanced.MetadataConflictsAvatarColor", "Avatar color"), + "encryptionPolicy" => L("Projects.Stat.EncryptionPolicy", "Encryption policy"), + "preferredDestinationId" => L("Projects.Stat.DestinationLabel", "Destination"), + "restoreMode" => L("Backups.Restore.Mode.Label", "Restore mode"), + "verificationPolicy" => L("Backups.Verification.Policy.Label", "Verification policy"), + "autoBackupEnabled" => L("Backups.Section.AutoBackups", "Automatic backup"), + "tags" => L("Projects.Tags.Label", "Tags"), + _ => field + })); + + private string FormatConflictProvenance(ProjectMetadataConflictRecord conflict) + { + string baseLabel = L("Settings.Advanced.MetadataConflictsBaseLabel", "Base"); + string localLabel = L("Settings.Advanced.MetadataConflictsLocalLabel", "Local"); + string remoteLabel = L("Settings.Advanced.MetadataConflictsImportedLabel", "Imported"); + string baseWriter = string.IsNullOrWhiteSpace(conflict.BaseMachineId) ? "-" : conflict.BaseMachineId; + string localWriter = string.IsNullOrWhiteSpace(conflict.LocalMachineId) ? "this-installation" : conflict.LocalMachineId; + string remoteWriter = string.IsNullOrWhiteSpace(conflict.SourceMachineId) ? "unknown" : conflict.SourceMachineId; + return $"{baseLabel} r{conflict.BaseRevision}: {baseWriter} · {FormatConflictUtc(conflict.BaseUpdatedUtc)} | " + + $"{localLabel}: {localWriter} · {FormatConflictUtc(conflict.DetectedUtc)} | " + + $"{remoteLabel} r{conflict.SourceRevision}: {remoteWriter} · {FormatConflictUtc(conflict.SourceUpdatedUtc)}"; + } + private string FormatConflictBoolean(bool? value) => value switch { @@ -4160,6 +4235,104 @@ private static string FormatConflictUtc(string? value) : value; } + private void RefreshProjectMetadataUndo(IEnumerable? resolutions) + { + ProjectMetadataResolutionRecord? latest = (resolutions ?? []) + .Where(static resolution => resolution.UndoAvailable) + .OrderByDescending(static resolution => resolution.ResolvedUtc, StringComparer.Ordinal) + .FirstOrDefault(); + HasUndoableProjectMetadataResolution = latest is not null; + ProjectMetadataUndoStatus = latest is null + ? L("Settings.Advanced.MetadataUndoNone", "No metadata resolution is currently undoable.") + : string.Format( + CultureInfo.CurrentCulture, + L("Settings.Advanced.MetadataUndoAvailable", "The last decision for {0} can be undone until the next repository write."), + latest.ProjectExternalId); + } + + private void UndoProjectMetadataResolution() + => _ = DetachedTask.RunAsync(UndoProjectMetadataResolutionAsync, nameof(UndoProjectMetadataResolutionAsync)); + + private async Task UndoProjectMetadataResolutionAsync() + { + await Dispatcher.UIThread.InvokeAsync(() => IsBackupIndexRepairBusy = true); + try + { + string projectName = await Task.Run(() => + { + AppConfig cfg = _configStore.Load(); + ProjectMetadataResolutionRecord resolution = (cfg.Advanced.ProjectMetadataResolutions ?? []) + .Where(static item => item.UndoAvailable) + .OrderByDescending(static item => item.ResolvedUtc, StringComparer.Ordinal) + .FirstOrDefault() + ?? throw new InvalidOperationException("The metadata resolution can no longer be undone."); + var repo = _repositoryFactory.Create(cfg); + Project current = repo.GetProjectByExternalId(resolution.ProjectExternalId) + ?? throw new InvalidOperationException("The project no longer exists."); + ApplyProjectMetadataValues(repo, cfg, current, resolution.Local); + resolution.UndoAvailable = false; + resolution.UndoneUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + UpdateMetadataConflictTelemetry(cfg, "undo", current.Name, cfg.Advanced.ProjectMetadataConflicts?.Count ?? 0); + _configStore.Save(cfg); + return current.Name; + }).ConfigureAwait(false); + + string status = string.Format( + CultureInfo.CurrentCulture, + L("Settings.Advanced.MetadataUndoComplete", "Restored the previous metadata for {0}."), + projectName); + await Dispatcher.UIThread.InvokeAsync(() => + { + LoadFromConfig(); + ProjectMetadataUndoStatus = status; + SaveStatus = status; + GlobalNotificationCenter.Instance.Show( + status, + NotificationSeverity.Info, + L(MetadataConflictsTitleKey, MetadataConflictsTitleFallback)); + }); + } + catch (Exception ex) + { + string status = string.Format( + CultureInfo.CurrentCulture, + L("Settings.Advanced.MetadataUndoFailed", "Undoing the metadata decision failed: {0}"), + ex.Message); + await Dispatcher.UIThread.InvokeAsync(() => + { + ProjectMetadataUndoStatus = status; + SaveStatus = status; + GlobalNotificationCenter.Instance.Show( + status, + NotificationSeverity.Error, + L(MetadataConflictsTitleKey, MetadataConflictsTitleFallback)); + }); + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => IsBackupIndexRepairBusy = false); + } + } + + private static void ApplyProjectMetadataValues( + SqliteRepository repo, + AppConfig cfg, + Project current, + ProjectMetadataConflictValues values) + { + repo.UpdateProjectEncryptionSettings( + current.Id, + string.IsNullOrWhiteSpace(values.EncryptionPolicy) ? current.EncryptionPolicy : values.EncryptionPolicy, + current.EncryptionKeyRef); + repo.UpdateProjectPreferredDestination(current.Id, EmptyToNull(values.PreferredDestinationId)); + repo.UpdateProjectRestoreMode(current.Id, EmptyToNull(values.RestoreMode)); + repo.UpdateProjectVerificationPolicy(current.Id, EmptyToNull(values.VerificationPolicy)); + repo.UpdateProjectTags(current.Id, EmptyToNull(values.Tags)); + ApplyResolvedAutoBackupSetting(cfg, current.Id, values.AutoBackupEnabled); + if (!string.IsNullOrWhiteSpace(values.AvatarColor)) + AvatarColorProvider.SetColorForExternalId(current.ExternalId, values.AvatarColor); + } + private void AcceptProjectMetadataConflict(ProjectMetadataConflictItemViewModel? item) { if (item is null) @@ -4181,7 +4354,9 @@ await Task.Run(() => var repo = _repositoryFactory.Create(cfg); ProjectMetadataConflictRecord conflict = FindProjectMetadataConflictRecord(cfg, item.ProjectId, item.ProjectExternalId) ?? throw new InvalidOperationException("The metadata conflict is no longer pending."); - ProjectMetadataConflictValues imported = conflict.AcceptImportedResult ?? conflict.Imported ?? new ProjectMetadataConflictValues(); + ProjectMetadataConflictValues imported = SelectConflictResult( + conflict.AcceptImportedResult, + conflict.Imported); Project? current = repo.GetProjectById(item.ProjectId); if (current is null) throw new InvalidOperationException("The project no longer exists."); @@ -4271,7 +4446,9 @@ await Task.Run(() => var repo = _repositoryFactory.Create(cfg); Project? current = repo.GetProjectById(item.ProjectId) ?? throw new InvalidOperationException("The project no longer exists."); - ProjectMetadataConflictValues result = conflict.KeepLocalResult ?? conflict.Local ?? new ProjectMetadataConflictValues(); + ProjectMetadataConflictValues result = SelectConflictResult( + conflict.KeepLocalResult, + conflict.Local); repo.UpdateProjectEncryptionSettings(item.ProjectId, result.EncryptionPolicy, current.EncryptionKeyRef); repo.UpdateProjectPreferredDestination(item.ProjectId, EmptyToNull(result.PreferredDestinationId)); repo.UpdateProjectRestoreMode(item.ProjectId, EmptyToNull(result.RestoreMode)); @@ -4338,6 +4515,13 @@ private static void RemoveProjectMetadataConflictRecord(AppConfig cfg, int proje } } + private static ProjectMetadataConflictValues SelectConflictResult( + ProjectMetadataConflictValues? preferred, + ProjectMetadataConflictValues? fallback) + => preferred?.AutoBackupEnabled.HasValue == true + ? preferred + : fallback ?? new ProjectMetadataConflictValues(); + private static ProjectMetadataConflictRecord? FindProjectMetadataConflictRecord( AppConfig cfg, int projectId, @@ -4353,6 +4537,14 @@ private static void RecordProjectMetadataResolution( string decision) { cfg.Advanced.ProjectMetadataResolutions ??= []; + string supersededUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + foreach (ProjectMetadataResolutionRecord previous in cfg.Advanced.ProjectMetadataResolutions.Where(existing => + existing.UndoAvailable && + string.Equals(existing.ProjectExternalId, conflict.ProjectExternalId, StringComparison.OrdinalIgnoreCase))) + { + previous.UndoAvailable = false; + previous.SupersededUtc = supersededUtc; + } cfg.Advanced.ProjectMetadataResolutions.RemoveAll(existing => string.Equals(existing.ProjectExternalId, conflict.ProjectExternalId, StringComparison.OrdinalIgnoreCase) && string.Equals(existing.SourceMachineId, conflict.SourceMachineId, StringComparison.Ordinal) && @@ -4367,11 +4559,12 @@ private static void RecordProjectMetadataResolution( BaseRevision = conflict.BaseRevision, Decision = decision, ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + UndoAvailable = true, Local = conflict.Local ?? new ProjectMetadataConflictValues(), Imported = conflict.Imported ?? new ProjectMetadataConflictValues(), Result = string.Equals(decision, "accept-imported", StringComparison.OrdinalIgnoreCase) - ? conflict.AcceptImportedResult ?? conflict.Imported ?? new ProjectMetadataConflictValues() - : conflict.KeepLocalResult ?? conflict.Local ?? new ProjectMetadataConflictValues() + ? SelectConflictResult(conflict.AcceptImportedResult, conflict.Imported) + : SelectConflictResult(conflict.KeepLocalResult, conflict.Local) }); const int maxResolutionRecords = 100; diff --git a/src/VaultSync.UI/Views/SettingsView.axaml b/src/VaultSync.UI/Views/SettingsView.axaml index b146f30b..32a63e71 100644 --- a/src/VaultSync.UI/Views/SettingsView.axaml +++ b/src/VaultSync.UI/Views/SettingsView.axaml @@ -2146,19 +2146,33 @@ + + + + - + @@ -2167,9 +2181,12 @@ Text="{infra:LocalizedString Key=Settings.Advanced.MetadataConflictsAvatarColor}" /> + Text="{Binding BaseAvatarColor}" /> + + Text="{Binding BaseEncryptionPolicy}" /> + + Text="{Binding BasePreferredDestinationId}" /> + + Text="{Binding BaseRestoreMode}" /> + + Text="{Binding BaseVerificationPolicy}" /> + + Text="{Binding BaseAutoBackupEnabled}" /> + + @@ -2255,6 +2291,21 @@ + + + +