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.
+

## 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).
+

## 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/src/VaultSync.UI/Views/SnapshotExplorerView.axaml b/src/VaultSync.UI/Views/SnapshotExplorerView.axaml
index c637bab9..71239a29 100644
--- a/src/VaultSync.UI/Views/SnapshotExplorerView.axaml
+++ b/src/VaultSync.UI/Views/SnapshotExplorerView.axaml
@@ -5,124 +5,121 @@
xmlns:vm="clr-namespace:VaultSync.UI.ViewModels"
x:Class="VaultSync.UI.Views.SnapshotExplorerView"
x:DataType="vm:SnapshotExplorerViewModel"
- Width="980"
- Height="680"
- MinWidth="780"
- MinHeight="520"
+ Width="1180"
+ Height="760"
+ MinWidth="860"
+ MinHeight="560"
+ WindowStartupLocation="CenterOwner"
Title="{Binding Title}">
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+ CornerRadius="12"
+ ClipToBounds="True">
-
+
+
+
+ SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
+ Background="Transparent"
+ BorderThickness="0">
-
-
-
+
+
-
+
-
-
-
-
+
+
+
@@ -132,47 +129,56 @@
+ CornerRadius="12"
+ ClipToBounds="True">
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ Foreground="{DynamicResource TextSecondary}" />
-
-
-
+
+
+ Foreground="{DynamicResource TextPrimary}" />
@@ -180,9 +186,15 @@
-
+
+
+
diff --git a/src/VaultSync.UI/Views/UpdaterWindow.axaml b/src/VaultSync.UI/Views/UpdaterWindow.axaml
index f4c42847..4e337522 100644
--- a/src/VaultSync.UI/Views/UpdaterWindow.axaml
+++ b/src/VaultSync.UI/Views/UpdaterWindow.axaml
@@ -1,54 +1,82 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
From e4027420d8290a02a24bdc54b2db1c3801fee673 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:32:47 +0200
Subject: [PATCH 29/54] Standardize and harden backup presets
---
src/VaultSync.Core/Services/FilterService.cs | 3 +-
src/VaultSync.Core/Services/RobocopyRunner.cs | 166 ++----------------
src/presets/ableton.vaultsyncignore | 2 -
.../adobe_after_effects.vaultsyncignore | 3 -
src/presets/adobe_premiere.vaultsyncignore | 3 -
src/presets/avalonia.vaultsyncignore | 15 +-
src/presets/blender.vaultsyncignore | 3 -
src/presets/c_cpp.vaultsyncignore | 3 -
src/presets/davinci_resolve.vaultsyncignore | 2 -
src/presets/docker.vaultsyncignore | 2 -
src/presets/dotnet.vaultsyncignore | 8 +-
src/presets/fl_studio.vaultsyncignore | 1 -
src/presets/flutter.vaultsyncignore | 3 -
src/presets/gamemaker.vaultsyncignore | 3 -
src/presets/generic.vaultsyncignore | 23 +++
src/presets/go.vaultsyncignore | 3 -
src/presets/godot.vaultsyncignore | 3 -
src/presets/java.vaultsyncignore | 3 -
src/presets/jetbrains.vaultsyncignore | 19 +-
src/presets/kubernetes.vaultsyncignore | 2 -
src/presets/maya.vaultsyncignore | 3 -
src/presets/node.vaultsyncignore | 19 +-
src/presets/presets.index.json | 8 +-
src/presets/python.vaultsyncignore | 22 ++-
src/presets/react_native.vaultsyncignore | 3 -
src/presets/rust.vaultsyncignore | 3 -
src/presets/terraform.vaultsyncignore | 2 -
src/presets/unity.vaultsyncignore | 1 -
src/presets/unreal.vaultsyncignore | 1 -
src/presets/video.vaultsyncignore | 1 -
src/presets/vscode.vaultsyncignore | 15 +-
.../BackupSafetyServiceTests.cs | 84 +++++++++
32 files changed, 205 insertions(+), 227 deletions(-)
diff --git a/src/VaultSync.Core/Services/FilterService.cs b/src/VaultSync.Core/Services/FilterService.cs
index b12274ad..412f8a82 100644
--- a/src/VaultSync.Core/Services/FilterService.cs
+++ b/src/VaultSync.Core/Services/FilterService.cs
@@ -6,6 +6,7 @@ namespace VaultSync.Core.Services;
public class FilterService
{
+ private static readonly TimeSpan s_regexTimeout = TimeSpan.FromMilliseconds(250);
private readonly List _patterns;
private readonly List _compiledPatterns;
private static readonly ConcurrentDictionary s_linesCache = new();
@@ -224,7 +225,7 @@ private static Regex CompilePattern(string pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*")
.Replace(@"\?", ".") + "$";
- return new Regex(rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ return new Regex(rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, s_regexTimeout);
}
private sealed class PresetIndex
diff --git a/src/VaultSync.Core/Services/RobocopyRunner.cs b/src/VaultSync.Core/Services/RobocopyRunner.cs
index 6fb72470..0d7cb8e2 100644
--- a/src/VaultSync.Core/Services/RobocopyRunner.cs
+++ b/src/VaultSync.Core/Services/RobocopyRunner.cs
@@ -4,7 +4,6 @@
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
@@ -21,10 +20,6 @@ public sealed class RobocopyRunner : ISyncRunner
{
public string Name => "robocopy";
private readonly bool _isNetworkDestination;
- private static readonly Dictionary Map)> s_presetIndexCache
- = new(StringComparer.OrdinalIgnoreCase);
- private static readonly object s_presetIndexLock = new();
-
public RobocopyRunner(bool isNetworkDestination = false)
{
_isNetworkDestination = isNetworkDestination;
@@ -276,30 +271,17 @@ private static (List files, List dirs) LoadExcludes(Project proj
var files = new List();
var dirs = new List();
- // 1) Preset file resolved using the same rules as the new preset system
- // (env override, app presets/, src/presets/, user ~/.vaultsync/presets).
- if (!string.IsNullOrWhiteSpace(project.Preset))
- {
- string? presetPath = ResolvePresetFile(project.Preset);
- if (!string.IsNullOrWhiteSpace(presetPath))
- {
- MergeIgnoreFile(presetPath!, files, dirs);
- }
- }
-
- // 2) Project-local .vaultsyncignore (always allowed to override/add rules)
- string localIgnore = Path.Combine(project.RootPath, ".vaultsyncignore");
- MergeIgnoreFile(localIgnore, files, dirs);
+ // Use the same resolved, normalized preset + local + reserved rules as
+ // snapshots and managed-copy backups. Platform runners must not drift.
+ FilterService filter = FilterService.FromPresetAndLocal(project.RootPath, project.Preset);
+ MergeIgnorePatterns(filter.RawPatterns, files, dirs);
return (files, dirs);
}
- private static void MergeIgnoreFile(string path, List files, List dirs)
+ private static void MergeIgnorePatterns(IEnumerable patterns, List files, List dirs)
{
- if (!File.Exists(path))
- return;
-
- foreach (string raw in File.ReadAllLines(path))
+ foreach (string raw in patterns)
{
string line = raw.Trim();
@@ -307,13 +289,17 @@ private static void MergeIgnoreFile(string path, List files, List files, List/presets
- string appDir = Path.Combine(AppContext.BaseDirectory, "presets");
- string? appPreset = ResolvePresetFileInDirectory(appDir, presetName);
- if (!string.IsNullOrWhiteSpace(appPreset))
- return appPreset;
-
- // 3) Dev tree: walk up to find src/presets
- string dir = AppContext.BaseDirectory;
- for (int i = 0; i < 6; i++)
- {
- string candidateDir = Path.Combine(dir, "src", "presets");
- string? candidate = ResolvePresetFileInDirectory(candidateDir, presetName);
- if (!string.IsNullOrWhiteSpace(candidate))
- return candidate;
-
- string? parent = Directory.GetParent(dir)?.FullName;
- if (parent is null)
- break;
-
- dir = parent;
- }
-
- // 4) User presets: ~/.vaultsync/presets
- string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- string userDir = Path.Combine(home, ".vaultsync", "presets");
- string? userPreset = ResolvePresetFileInDirectory(userDir, presetName);
- if (!string.IsNullOrWhiteSpace(userPreset))
- return userPreset;
-
- return null;
- }
-
- private static string? ResolvePresetFileInDirectory(string? directory, string presetName)
- {
- if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
- return null;
-
- string direct = Path.Combine(directory, $"{presetName}.vaultsyncignore");
- if (File.Exists(direct))
- return direct;
-
- string? mapped = ResolvePresetFileFromIndex(directory, presetName);
- if (!string.IsNullOrWhiteSpace(mapped))
- return mapped;
-
- return null;
- }
-
- private static string? ResolvePresetFileFromIndex(string directory, string presetName)
- {
- try
- {
- string indexPath = Path.Combine(directory, "presets.index.json");
- if (!File.Exists(indexPath))
- return null;
-
- Dictionary map;
- lock (s_presetIndexLock)
- {
- DateTime lastWrite = File.GetLastWriteTimeUtc(indexPath);
- if (!s_presetIndexCache.TryGetValue(indexPath, out (DateTime LastWriteUtc, Dictionary Map) cached) || cached.LastWriteUtc != lastWrite)
- {
- string json = File.ReadAllText(indexPath);
- PresetIndex? index = JsonSerializer.Deserialize(json);
- var parsed = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- if (index?.Presets != null)
- {
- foreach (PresetInfo preset in index.Presets)
- {
- if (string.IsNullOrWhiteSpace(preset.Id) || string.IsNullOrWhiteSpace(preset.File))
- continue;
-
- parsed[preset.Id] = preset.File;
- }
- }
-
- cached = (lastWrite, parsed);
- s_presetIndexCache[indexPath] = cached;
- }
-
- map = cached.Map;
- }
-
- if (!map.TryGetValue(presetName, out string? fileName) || string.IsNullOrWhiteSpace(fileName))
- return null;
-
- string candidate = Path.Combine(directory, fileName);
- return File.Exists(candidate) ? candidate : null;
- }
- catch
- {
- return null;
- }
- }
-
private static string NormalizeRobocopyGlob(string pattern)
{
// Convert forward slashes to backslashes; keep wildcards as-is.
@@ -539,16 +415,6 @@ private static string NormalizeWinPath(string path)
return null;
}
- private sealed class PresetIndex
- {
- public List Presets { get; set; } = new();
- }
-
- private sealed class PresetInfo
- {
- public string Id { get; set; } = string.Empty;
- public string File { get; set; } = string.Empty;
- }
private static string TrimLog(StringBuilder sb, int maxChars = 4000)
{
diff --git a/src/presets/ableton.vaultsyncignore b/src/presets/ableton.vaultsyncignore
index c668ceeb..5b5d5a45 100644
--- a/src/presets/ableton.vaultsyncignore
+++ b/src/presets/ableton.vaultsyncignore
@@ -4,5 +4,3 @@ Ableton Projects/*/Samples/**
*.asd
.DS_Store
Thumbs.db
-
-.git/
diff --git a/src/presets/adobe_after_effects.vaultsyncignore b/src/presets/adobe_after_effects.vaultsyncignore
index fecfd96c..a819749e 100644
--- a/src/presets/adobe_after_effects.vaultsyncignore
+++ b/src/presets/adobe_after_effects.vaultsyncignore
@@ -9,6 +9,3 @@ Thumbs.db
# Caches
Adobe After Effects*/**
~**
-
-# Git
-.git/
diff --git a/src/presets/adobe_premiere.vaultsyncignore b/src/presets/adobe_premiere.vaultsyncignore
index 04fde017..fad9e8a2 100644
--- a/src/presets/adobe_premiere.vaultsyncignore
+++ b/src/presets/adobe_premiere.vaultsyncignore
@@ -7,6 +7,3 @@ Media Cache Files/**
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/avalonia.vaultsyncignore b/src/presets/avalonia.vaultsyncignore
index 5d2e028a..38eafe3f 100644
--- a/src/presets/avalonia.vaultsyncignore
+++ b/src/presets/avalonia.vaultsyncignore
@@ -27,9 +27,18 @@ packages/**
.vscode/
.vs/
.idea/
+.blueprints/**
+log/**
+logs/**
+**/.pytest_cache/**
+**/.mypy_cache/**
+**/.ruff_cache/**
+**/node_modules/**
+**/.next/**
+**/.turbo/**
+**/.parcel-cache/**
# OS
.DS_Store
-
-# Git
-.git/
+._*
+Thumbs.db
diff --git a/src/presets/blender.vaultsyncignore b/src/presets/blender.vaultsyncignore
index 08761552..72517f0f 100644
--- a/src/presets/blender.vaultsyncignore
+++ b/src/presets/blender.vaultsyncignore
@@ -14,6 +14,3 @@ autosave/**
*.tmp
Thumbs.db
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/c_cpp.vaultsyncignore b/src/presets/c_cpp.vaultsyncignore
index b328986d..e40ca52f 100644
--- a/src/presets/c_cpp.vaultsyncignore
+++ b/src/presets/c_cpp.vaultsyncignore
@@ -28,6 +28,3 @@ Makefile
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/davinci_resolve.vaultsyncignore b/src/presets/davinci_resolve.vaultsyncignore
index 8aba90d0..da211637 100644
--- a/src/presets/davinci_resolve.vaultsyncignore
+++ b/src/presets/davinci_resolve.vaultsyncignore
@@ -12,5 +12,3 @@ AudioCache/**
*.log
.DS_Store
Thumbs.db
-
-.git/
diff --git a/src/presets/docker.vaultsyncignore b/src/presets/docker.vaultsyncignore
index b600abbf..e1dadbb4 100644
--- a/src/presets/docker.vaultsyncignore
+++ b/src/presets/docker.vaultsyncignore
@@ -12,5 +12,3 @@
.DS_Store
Thumbs.db
-
-.git/
diff --git a/src/presets/dotnet.vaultsyncignore b/src/presets/dotnet.vaultsyncignore
index cfb0c7ec..c73afee7 100644
--- a/src/presets/dotnet.vaultsyncignore
+++ b/src/presets/dotnet.vaultsyncignore
@@ -5,6 +5,10 @@ obj/**
*.exe
TestResults/**
**/TestResults/**
+artifacts/**
+**/artifacts/**
+coverage/**
+**/coverage/**
*.user
*.csproj.user
*.suo
@@ -22,7 +26,5 @@ TestResults/**
# OS
.DS_Store
+._*
Thumbs.db
-
-# Git
-.git/
diff --git a/src/presets/fl_studio.vaultsyncignore b/src/presets/fl_studio.vaultsyncignore
index e5e3dcd1..50934210 100644
--- a/src/presets/fl_studio.vaultsyncignore
+++ b/src/presets/fl_studio.vaultsyncignore
@@ -8,4 +8,3 @@ Rendered/**
.DS_Store
Thumbs.db
-.git/
diff --git a/src/presets/flutter.vaultsyncignore b/src/presets/flutter.vaultsyncignore
index 2439aa8f..24b9ae6f 100644
--- a/src/presets/flutter.vaultsyncignore
+++ b/src/presets/flutter.vaultsyncignore
@@ -15,6 +15,3 @@ build/**
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/gamemaker.vaultsyncignore b/src/presets/gamemaker.vaultsyncignore
index 88af3846..113b25ad 100644
--- a/src/presets/gamemaker.vaultsyncignore
+++ b/src/presets/gamemaker.vaultsyncignore
@@ -19,6 +19,3 @@ cache/
# OS
.DS_Store
Thumbs.db
-
-# Git
-.git/
diff --git a/src/presets/generic.vaultsyncignore b/src/presets/generic.vaultsyncignore
index 5e89ce4a..c2a71b5e 100644
--- a/src/presets/generic.vaultsyncignore
+++ b/src/presets/generic.vaultsyncignore
@@ -13,6 +13,17 @@ cache/**
.cache/**
coverage/**
logs/**
+log/**
+.blueprints/**
+.pytest_cache/**
+.mypy_cache/**
+.ruff_cache/**
+node_modules/**
+.next/**
+.nuxt/**
+.svelte-kit/**
+.turbo/**
+.parcel-cache/**
**/bin/**
**/obj/**
@@ -26,6 +37,17 @@ logs/**
**/.cache/**
**/coverage/**
**/logs/**
+**/log/**
+**/.blueprints/**
+**/.pytest_cache/**
+**/.mypy_cache/**
+**/.ruff_cache/**
+**/node_modules/**
+**/.next/**
+**/.nuxt/**
+**/.svelte-kit/**
+**/.turbo/**
+**/.parcel-cache/**
*.tmp
*.temp
@@ -45,5 +67,6 @@ logs/**
# OS
.DS_Store
+._*
Thumbs.db
desktop.ini
diff --git a/src/presets/go.vaultsyncignore b/src/presets/go.vaultsyncignore
index ccf8c844..0eee404c 100644
--- a/src/presets/go.vaultsyncignore
+++ b/src/presets/go.vaultsyncignore
@@ -12,6 +12,3 @@ pkg/**
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/godot.vaultsyncignore b/src/presets/godot.vaultsyncignore
index b4832b4d..de981c51 100644
--- a/src/presets/godot.vaultsyncignore
+++ b/src/presets/godot.vaultsyncignore
@@ -18,6 +18,3 @@ export_presets.cfg
# OS
.DS_Store
Thumbs.db
-
-# Git
-.git/
diff --git a/src/presets/java.vaultsyncignore b/src/presets/java.vaultsyncignore
index 60950816..939e0615 100644
--- a/src/presets/java.vaultsyncignore
+++ b/src/presets/java.vaultsyncignore
@@ -17,6 +17,3 @@ build/**
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/jetbrains.vaultsyncignore b/src/presets/jetbrains.vaultsyncignore
index 28cc000e..b6a29eff 100644
--- a/src/presets/jetbrains.vaultsyncignore
+++ b/src/presets/jetbrains.vaultsyncignore
@@ -1,9 +1,22 @@
-.idea/**
+# Keep shareable project configuration while dropping per-user IDE state.
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/usage.statistics.xml
+.idea/dictionaries/**
+.idea/shelf/**
+.idea/httpRequests/**
+.idea/dataSources.local.xml
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries/**
+.idea/**/shelf/**
+.idea/**/httpRequests/**
+.idea/**/dataSources.local.xml
*.iml
out/**
**/out/**
.DS_Store
+._*
Thumbs.db
-
-.git/
diff --git a/src/presets/kubernetes.vaultsyncignore b/src/presets/kubernetes.vaultsyncignore
index 1c23de11..bc978e04 100644
--- a/src/presets/kubernetes.vaultsyncignore
+++ b/src/presets/kubernetes.vaultsyncignore
@@ -3,5 +3,3 @@
*.bak
.DS_Store
Thumbs.db
-
-.git/
\ No newline at end of file
diff --git a/src/presets/maya.vaultsyncignore b/src/presets/maya.vaultsyncignore
index 32043101..97647b46 100644
--- a/src/presets/maya.vaultsyncignore
+++ b/src/presets/maya.vaultsyncignore
@@ -7,6 +7,3 @@ images/**
# OS
.DS_Store
Thumbs.db
-
-# Git
-.git/
diff --git a/src/presets/node.vaultsyncignore b/src/presets/node.vaultsyncignore
index 4701fb62..3c653cba 100644
--- a/src/presets/node.vaultsyncignore
+++ b/src/presets/node.vaultsyncignore
@@ -2,10 +2,24 @@ node_modules/**
dist/**
build/**
.cache/**
+.next/**
+.nuxt/**
+.svelte-kit/**
+.turbo/**
+.parcel-cache/**
+.vite/**
+.eslintcache
+.stylelintcache
**/node_modules/**
**/dist/**
**/build/**
**/.cache/**
+**/.next/**
+**/.nuxt/**
+**/.svelte-kit/**
+**/.turbo/**
+**/.parcel-cache/**
+**/.vite/**
*.log
*.tmp
@@ -23,6 +37,5 @@ lerna-debug.log*
# OS
.DS_Store
-
-# Git
-.git/
+._*
+Thumbs.db
diff --git a/src/presets/presets.index.json b/src/presets/presets.index.json
index 1013215e..2bcd05bc 100644
--- a/src/presets/presets.index.json
+++ b/src/presets/presets.index.json
@@ -1,5 +1,5 @@
{
- "version": 1,
+ "version": 2,
"presets": [
{
"id": "generic",
@@ -106,7 +106,7 @@
"name": "Avalonia",
"category": "Mobile / UI",
"file": "avalonia.vaultsyncignore",
- "description": "Excludes bin, obj, pdb, ide caches."
+ "description": "Excludes build output, test artefacts, caches, local tooling state, and IDE junk while preserving repository history."
},
{
@@ -228,14 +228,14 @@
"name": "VSCode",
"category": "IDEs",
"file": "vscode.vaultsyncignore",
- "description": "Ignore .vscode workspace junk except settings and recommendations."
+ "description": "Keeps shareable editor settings and excludes only machine-local indexes, history, and workspace state."
},
{
"id": "jetbrains",
"name": "JetBrains IDEs",
"category": "IDEs",
"file": "jetbrains.vaultsyncignore",
- "description": "Excludes .idea folder and IntelliJ output directories."
+ "description": "Keeps shareable project configuration while excluding per-user IDE state and generated output."
}
]
}
diff --git a/src/presets/python.vaultsyncignore b/src/presets/python.vaultsyncignore
index e2736b91..c482906b 100644
--- a/src/presets/python.vaultsyncignore
+++ b/src/presets/python.vaultsyncignore
@@ -10,11 +10,24 @@ dist/**
**/build/**
**/dist/**
pip-wheel-metadata/**
-pytest_cache/**
+.pytest_cache/**
+.mypy_cache/**
+.ruff_cache/**
+.pytype/**
+.tox/**
+.nox/**
htmlcov/**
**/pip-wheel-metadata/**
-**/pytest_cache/**
+**/.pytest_cache/**
+**/.mypy_cache/**
+**/.ruff_cache/**
+**/.pytype/**
+**/.tox/**
+**/.nox/**
**/htmlcov/**
+.coverage
+.coverage.*
+coverage.xml
# environments
env/**
@@ -30,6 +43,5 @@ venv/**
# OS
.DS_Store
-
-# Git
-.git/
+._*
+Thumbs.db
diff --git a/src/presets/react_native.vaultsyncignore b/src/presets/react_native.vaultsyncignore
index e1249f68..f496c91e 100644
--- a/src/presets/react_native.vaultsyncignore
+++ b/src/presets/react_native.vaultsyncignore
@@ -19,6 +19,3 @@ node_modules/**
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/rust.vaultsyncignore b/src/presets/rust.vaultsyncignore
index 3a029c07..77b2eafb 100644
--- a/src/presets/rust.vaultsyncignore
+++ b/src/presets/rust.vaultsyncignore
@@ -10,6 +10,3 @@ Cargo.lock.bak
# OS
.DS_Store
-
-# Git
-.git/
diff --git a/src/presets/terraform.vaultsyncignore b/src/presets/terraform.vaultsyncignore
index 6b75ba58..3aa5a53f 100644
--- a/src/presets/terraform.vaultsyncignore
+++ b/src/presets/terraform.vaultsyncignore
@@ -9,5 +9,3 @@ crash.*.log
.DS_Store
Thumbs.db
-
-.git/
\ No newline at end of file
diff --git a/src/presets/unity.vaultsyncignore b/src/presets/unity.vaultsyncignore
index d5c24f0f..7b8fe2bc 100644
--- a/src/presets/unity.vaultsyncignore
+++ b/src/presets/unity.vaultsyncignore
@@ -61,7 +61,6 @@ Thumbs.db
*.exe
# Git metadata
-.git/
# --- Temporary / backup / lock files ---
*.tmp
diff --git a/src/presets/unreal.vaultsyncignore b/src/presets/unreal.vaultsyncignore
index f97a023c..e81c5ecc 100644
--- a/src/presets/unreal.vaultsyncignore
+++ b/src/presets/unreal.vaultsyncignore
@@ -28,4 +28,3 @@ Thumbs.db
*.old
# Git / metadata
-.git/
diff --git a/src/presets/video.vaultsyncignore b/src/presets/video.vaultsyncignore
index 1ba598eb..f962cea4 100644
--- a/src/presets/video.vaultsyncignore
+++ b/src/presets/video.vaultsyncignore
@@ -12,4 +12,3 @@ RenderCache/**
.DS_Store
Thumbs.db
-.git/
diff --git a/src/presets/vscode.vaultsyncignore b/src/presets/vscode.vaultsyncignore
index aaaa81f5..35ff027b 100644
--- a/src/presets/vscode.vaultsyncignore
+++ b/src/presets/vscode.vaultsyncignore
@@ -1,7 +1,12 @@
-.vscode/**
-!.vscode/settings.json
-!.vscode/extensions.json
+# Keep shareable settings, tasks, launch profiles, snippets, and extension
+# recommendations. Exclude only machine-local indexes and state.
+.vscode/.ropeproject/**
+.vscode/.browse.VC.db
+.vscode/.browse.VC.db-wal
+.vscode/.browse.VC.db-shm
+.vscode/ipch/**
+.vscode/*.log
+.history/**
.DS_Store
+._*
Thumbs.db
-
-.git/
\ No newline at end of file
diff --git a/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs b/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
index d80ef5f0..25f6a647 100644
--- a/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
+++ b/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Threading.Tasks;
using VaultSync.Core.Models;
using VaultSync.Core.Services;
@@ -284,6 +285,8 @@ public void BuiltInSourcePresets_ExcludeGeneratedOutputsButKeepRepoMetadata(stri
WriteProjectFile(projectRoot, ".github/workflows/ci.yml", "workflow");
WriteProjectFile(projectRoot, ".gitignore", "bin/");
WriteProjectFile(projectRoot, ".gitattributes", "* text=auto");
+ WriteProjectFile(projectRoot, ".git/refs/heads/local-feature", "commit");
+ WriteProjectFile(projectRoot, ".git/objects/aa/object", "object");
WriteProjectFile(projectRoot, generatedPath, "generated");
var filter = FilterService.FromPresetAndLocal(projectRoot, preset, ResolveRepoPresetsDir());
@@ -294,9 +297,90 @@ public void BuiltInSourcePresets_ExcludeGeneratedOutputsButKeepRepoMetadata(stri
Assert.Contains(".github/workflows/ci.yml", entries);
Assert.Contains(".gitignore", entries);
Assert.Contains(".gitattributes", entries);
+ Assert.Contains(".git/refs/heads/local-feature", entries);
+ Assert.Contains(".git/objects/aa/object", entries);
Assert.DoesNotContain(entries, path => path.Equals(generatedPath, StringComparison.OrdinalIgnoreCase));
}
+ [Theory]
+ [InlineData("generic", ".blueprints/cache/state.json")]
+ [InlineData("generic", "tools/.pytest_cache/nodeids")]
+ [InlineData("generic", "web/.next/cache/bundle.bin")]
+ [InlineData("avalonia", "log/session.log")]
+ [InlineData("avalonia", "tools/.ruff_cache/index")]
+ [InlineData("python", ".pytest_cache/nodeids")]
+ [InlineData("python", "module/.mypy_cache/types.json")]
+ [InlineData("python", "module/.ruff_cache/index")]
+ [InlineData("node", ".next/cache/bundle.bin")]
+ [InlineData("node", "packages/app/.turbo/state.json")]
+ [InlineData("node", "packages/app/.parcel-cache/data.bin")]
+ public void BuiltInPresets_ExcludeModernGeneratedState(string preset, string generatedPath)
+ {
+ string projectRoot = Path.Combine(_tempDir.Path, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectRoot);
+ WriteProjectFile(projectRoot, "src/source.txt", "keep");
+ WriteProjectFile(projectRoot, generatedPath, "generated");
+
+ var scanner = new ScannerService(FilterService.FromPresetAndLocal(projectRoot, preset, ResolveRepoPresetsDir()));
+ string[] entries = scanner.Scan(projectRoot).Select(entry => entry.RelPath).ToArray();
+
+ Assert.Contains("src/source.txt", entries);
+ Assert.DoesNotContain(entries, path => path.Equals(generatedPath, StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void EditorPresets_KeepShareableConfigurationAndExcludeLocalState()
+ {
+ string projectRoot = Path.Combine(_tempDir.Path, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectRoot);
+ WriteProjectFile(projectRoot, ".vscode/settings.json", "{}");
+ WriteProjectFile(projectRoot, ".vscode/tasks.json", "{}");
+ WriteProjectFile(projectRoot, ".vscode/.browse.VC.db", "generated");
+ WriteProjectFile(projectRoot, ".idea/modules.xml", "");
+ WriteProjectFile(projectRoot, ".idea/workspace.xml", "generated");
+
+ string[] vscodeEntries = new ScannerService(
+ FilterService.FromPresetAndLocal(projectRoot, "vscode", ResolveRepoPresetsDir()))
+ .Scan(projectRoot).Select(entry => entry.RelPath).ToArray();
+ string[] jetBrainsEntries = new ScannerService(
+ FilterService.FromPresetAndLocal(projectRoot, "jetbrains", ResolveRepoPresetsDir()))
+ .Scan(projectRoot).Select(entry => entry.RelPath).ToArray();
+
+ Assert.Contains(".vscode/settings.json", vscodeEntries);
+ Assert.Contains(".vscode/tasks.json", vscodeEntries);
+ Assert.DoesNotContain(".vscode/.browse.VC.db", vscodeEntries);
+ Assert.Contains(".idea/modules.xml", jetBrainsEntries);
+ Assert.DoesNotContain(".idea/workspace.xml", jetBrainsEntries);
+ }
+
+ [Fact]
+ public void PresetCatalog_IsCompleteAndUsesSupportedExclusionRules()
+ {
+ string presetsDir = ResolveRepoPresetsDir();
+ string indexPath = Path.Combine(presetsDir, "presets.index.json");
+ using JsonDocument index = JsonDocument.Parse(File.ReadAllText(indexPath));
+
+ Assert.Equal(2, index.RootElement.GetProperty("version").GetInt32());
+ string[] indexedFiles = index.RootElement.GetProperty("presets")
+ .EnumerateArray()
+ .Select(preset => preset.GetProperty("file").GetString()!)
+ .ToArray();
+ string[] presetFiles = Directory.GetFiles(presetsDir, "*.vaultsyncignore")
+ .Select(file => Path.GetFileName(file)!)
+ .OrderBy(file => file, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ Assert.Equal(indexedFiles.Length, indexedFiles.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ Assert.Equal(presetFiles, indexedFiles.OrderBy(file => file, StringComparer.OrdinalIgnoreCase).ToArray());
+
+ foreach (string presetFile in presetFiles)
+ {
+ string[] rules = File.ReadAllLines(Path.Combine(presetsDir, presetFile));
+ Assert.DoesNotContain(rules, rule => rule.TrimStart().StartsWith('!'));
+ Assert.DoesNotContain(rules, rule => rule.Trim().Equals(".git/", StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
private static void WriteProjectFile(string projectRoot, string relativePath, string contents)
{
string fullPath = Path.Combine(projectRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
From 0ba5c30c6eba650256c77f564a4d4e5c8a5206ec Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:32:51 +0200
Subject: [PATCH 30/54] Document preset safety and finish utility polish
---
CHANGELOG.md | 3 +++
README.md | 4 ++++
docs/HELP.md | 5 +++++
docs/WHATS_NEW.md | 4 +++-
src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml | 4 ----
5 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25726483..d04e246d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,11 +11,14 @@
- [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.
+- [VS-1880] Unified Windows Robocopy exclusions with the shared preset resolver and modernized Snapshot Explorer, metadata-import review, and updater windows around the current compact, theme-aware app layout.
+- Reworked development presets to preserve complete Git history and shareable IDE configuration while excluding modern build, package, test, framework, and machine-local caches.
### 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.
+- Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
## [1.8.6] - 10.08.2026
### Added
diff --git a/README.md b/README.md
index 2d2b1f58..8359d515 100644
--- a/README.md
+++ b/README.md
@@ -203,6 +203,10 @@ Included presets cover:
- General project workflows
Choose **No preset** to include everything, or configure your own exclusion rules.
+Development presets remove disposable build output, package caches, test caches,
+logs, and machine-local IDE state while preserving Git repository history and
+shareable editor configuration. VaultSync rules are exclusion-only; `!` negation
+rules from `.gitignore` are not supported.
---
diff --git a/docs/HELP.md b/docs/HELP.md
index 2bd41ac8..d20ca32a 100644
--- a/docs/HELP.md
+++ b/docs/HELP.md
@@ -55,6 +55,11 @@ deleting source files or stored backup data.
- Steam mods
- Creative suite workspaces
- Projects show a short preset description and example hint under the preset selector.
+- Development presets keep `.git` repository history and shared editor/project
+ configuration while excluding generated output, package and test caches, logs,
+ and machine-local IDE indexes.
+- `.vaultsyncignore` supports exclusion rules only. Git-style `!` include/negation
+ rules are not supported, and VaultSync does not automatically import `.gitignore`.
## Destination Modes
VaultSync supports two destination modes:
diff --git a/docs/WHATS_NEW.md b/docs/WHATS_NEW.md
index 64c53bb0..b591d501 100644
--- a/docs/WHATS_NEW.md
+++ b/docs/WHATS_NEW.md
@@ -260,7 +260,9 @@ Current `1.7.5` highlights focus on making the codebase more reusable and mainta
### Presets and generated output
- Development and creative presets now exclude nested generated outputs such as build, cache, import, and render folders.
- Filter coverage now includes nested `**/bin/**`, `**/Intermediate/**`, `.import`, and render-cache style folders.
-- Source-code presets now keep useful repository metadata such as `.github` workflows and Git config files while still excluding `.git` internals and generated build outputs.
+- Source-code presets now keep the complete `.git` repository history, `.github`
+ workflows, Git config files, and shareable editor settings while excluding
+ generated build output and machine-local caches.
## [1.7.4]
diff --git a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml
index 9a193472..8bf3fba2 100644
--- a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml
+++ b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml
@@ -67,10 +67,6 @@
-
Date: Thu, 13 Aug 2026 16:37:34 +0200
Subject: [PATCH 31/54] Align preset safety contract and work item IDs
---
CHANGELOG.md | 7 ++++---
README.md | 7 ++++---
ROADMAP.md | 13 +++++++++++++
docs/HELP.md | 6 +++---
docs/RELEASE_1.8.7.md | 6 ++++++
docs/WHATS_NEW.md | 6 +++---
src/presets/ableton.vaultsyncignore | 1 +
src/presets/adobe_after_effects.vaultsyncignore | 1 +
src/presets/adobe_premiere.vaultsyncignore | 1 +
src/presets/avalonia.vaultsyncignore | 1 +
src/presets/blender.vaultsyncignore | 1 +
src/presets/c_cpp.vaultsyncignore | 1 +
src/presets/davinci_resolve.vaultsyncignore | 1 +
src/presets/docker.vaultsyncignore | 1 +
src/presets/dotnet.vaultsyncignore | 1 +
src/presets/fl_studio.vaultsyncignore | 1 +
src/presets/flutter.vaultsyncignore | 1 +
src/presets/gamemaker.vaultsyncignore | 1 +
src/presets/go.vaultsyncignore | 1 +
src/presets/godot.vaultsyncignore | 1 +
src/presets/java.vaultsyncignore | 1 +
src/presets/jetbrains.vaultsyncignore | 1 +
src/presets/kubernetes.vaultsyncignore | 1 +
src/presets/maya.vaultsyncignore | 1 +
src/presets/node.vaultsyncignore | 1 +
src/presets/presets.index.json | 2 +-
src/presets/python.vaultsyncignore | 1 +
src/presets/react_native.vaultsyncignore | 1 +
src/presets/rust.vaultsyncignore | 1 +
src/presets/terraform.vaultsyncignore | 1 +
src/presets/unity.vaultsyncignore | 1 +
src/presets/unreal.vaultsyncignore | 1 +
src/presets/video.vaultsyncignore | 1 +
src/presets/vscode.vaultsyncignore | 1 +
.../BackupSafetyServiceTests.cs | 7 +++----
35 files changed, 64 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d04e246d..31194468 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,14 +11,15 @@
- [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.
-- [VS-1880] Unified Windows Robocopy exclusions with the shared preset resolver and modernized Snapshot Explorer, metadata-import review, and updater windows around the current compact, theme-aware app layout.
-- Reworked development presets to preserve complete Git history and shareable IDE configuration while excluding modern build, package, test, framework, and machine-local caches.
+- [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-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.
-- Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
+- [BUG-18104] Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
## [1.8.6] - 10.08.2026
### Added
diff --git a/README.md b/README.md
index 8359d515..0d44b4e4 100644
--- a/README.md
+++ b/README.md
@@ -204,9 +204,10 @@ Included presets cover:
Choose **No preset** to include everything, or configure your own exclusion rules.
Development presets remove disposable build output, package caches, test caches,
-logs, and machine-local IDE state while preserving Git repository history and
-shareable editor configuration. VaultSync rules are exclusion-only; `!` negation
-rules from `.gitignore` are not supported.
+logs, and machine-local IDE state while preserving Git control files and shareable
+editor configuration. Live `.git` internals remain excluded until full-repository
+backup consistency and restore safeguards are available. VaultSync rules are
+exclusion-only; `!` negation rules from `.gitignore` are not supported.
---
diff --git a/ROADMAP.md b/ROADMAP.md
index 08a4a561..15869adf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -501,6 +501,19 @@ that exists only on the release branch or remains planned.
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.
+- [x] `BUG-18103` `P1` Modernize outdated utility windows and restore theme
+ consistency across Snapshot Explorer, metadata-import review, and updater UI.
+ - Acceptance: the utility windows use the current compact layout and dynamic
+ theme resources without regressing browsing, preview, import, or update
+ behavior.
+ - Completed on the 1.8.7 release branch on 2026-08-13 in PR #546.
+- [x] `BUG-18104` `P0` Correct stale preset exclusions and eliminate Windows
+ preset-resolution drift.
+ - Acceptance: current generated state is excluded, shared editor and Git
+ control files remain protected, live `.git` internals remain gated by
+ `VS-1801`, unsupported negation rules are absent, and every backup path uses
+ the shared preset resolver with regression coverage.
+ - Completed on the 1.8.7 release branch on 2026-08-13 in PR #546.
### Delivery sequence
diff --git a/docs/HELP.md b/docs/HELP.md
index d20ca32a..c77dcced 100644
--- a/docs/HELP.md
+++ b/docs/HELP.md
@@ -55,9 +55,9 @@ deleting source files or stored backup data.
- Steam mods
- Creative suite workspaces
- Projects show a short preset description and example hint under the preset selector.
-- Development presets keep `.git` repository history and shared editor/project
- configuration while excluding generated output, package and test caches, logs,
- and machine-local IDE indexes.
+- Development presets keep Git control files and shared editor/project
+ configuration while excluding live `.git` internals, generated output, package
+ and test caches, logs, and machine-local IDE indexes.
- `.vaultsyncignore` supports exclusion rules only. Git-style `!` include/negation
rules are not supported, and VaultSync does not automatically import `.gitignore`.
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 07ff850e..9eea6868 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -55,6 +55,12 @@ assumed and must be approved explicitly if the release needs one.
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.
+- Snapshot Explorer, metadata-import review, and updater windows now use the
+ current compact, theme-aware utility layout (`BUG-18103`).
+- Built-in development presets now cover current generated caches and local IDE
+ state without relying on unsupported negation rules; Windows Robocopy also
+ consumes the shared preset resolver. Live `.git` internals remain excluded
+ pending the separately gated full-repository mode (`BUG-18104`, `VS-1801`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/docs/WHATS_NEW.md b/docs/WHATS_NEW.md
index b591d501..ef8ea8e4 100644
--- a/docs/WHATS_NEW.md
+++ b/docs/WHATS_NEW.md
@@ -260,9 +260,9 @@ Current `1.7.5` highlights focus on making the codebase more reusable and mainta
### Presets and generated output
- Development and creative presets now exclude nested generated outputs such as build, cache, import, and render folders.
- Filter coverage now includes nested `**/bin/**`, `**/Intermediate/**`, `.import`, and render-cache style folders.
-- Source-code presets now keep the complete `.git` repository history, `.github`
- workflows, Git config files, and shareable editor settings while excluding
- generated build output and machine-local caches.
+- Source-code presets keep `.github` workflows, Git control files, and shareable
+ editor settings while excluding live `.git` internals, generated build output,
+ and machine-local caches.
## [1.7.4]
diff --git a/src/presets/ableton.vaultsyncignore b/src/presets/ableton.vaultsyncignore
index 5b5d5a45..737b9fde 100644
--- a/src/presets/ableton.vaultsyncignore
+++ b/src/presets/ableton.vaultsyncignore
@@ -4,3 +4,4 @@ Ableton Projects/*/Samples/**
*.asd
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/adobe_after_effects.vaultsyncignore b/src/presets/adobe_after_effects.vaultsyncignore
index a819749e..9beb7210 100644
--- a/src/presets/adobe_after_effects.vaultsyncignore
+++ b/src/presets/adobe_after_effects.vaultsyncignore
@@ -9,3 +9,4 @@ Thumbs.db
# Caches
Adobe After Effects*/**
~**
+.git/
diff --git a/src/presets/adobe_premiere.vaultsyncignore b/src/presets/adobe_premiere.vaultsyncignore
index fad9e8a2..302bd5f8 100644
--- a/src/presets/adobe_premiere.vaultsyncignore
+++ b/src/presets/adobe_premiere.vaultsyncignore
@@ -7,3 +7,4 @@ Media Cache Files/**
# OS
.DS_Store
+.git/
diff --git a/src/presets/avalonia.vaultsyncignore b/src/presets/avalonia.vaultsyncignore
index 38eafe3f..64eee22c 100644
--- a/src/presets/avalonia.vaultsyncignore
+++ b/src/presets/avalonia.vaultsyncignore
@@ -42,3 +42,4 @@ logs/**
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/src/presets/blender.vaultsyncignore b/src/presets/blender.vaultsyncignore
index 72517f0f..3c0088bc 100644
--- a/src/presets/blender.vaultsyncignore
+++ b/src/presets/blender.vaultsyncignore
@@ -14,3 +14,4 @@ autosave/**
*.tmp
Thumbs.db
.DS_Store
+.git/
diff --git a/src/presets/c_cpp.vaultsyncignore b/src/presets/c_cpp.vaultsyncignore
index e40ca52f..c24ed329 100644
--- a/src/presets/c_cpp.vaultsyncignore
+++ b/src/presets/c_cpp.vaultsyncignore
@@ -28,3 +28,4 @@ Makefile
# OS
.DS_Store
+.git/
diff --git a/src/presets/davinci_resolve.vaultsyncignore b/src/presets/davinci_resolve.vaultsyncignore
index da211637..b5dc8c81 100644
--- a/src/presets/davinci_resolve.vaultsyncignore
+++ b/src/presets/davinci_resolve.vaultsyncignore
@@ -12,3 +12,4 @@ AudioCache/**
*.log
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/docker.vaultsyncignore b/src/presets/docker.vaultsyncignore
index e1dadbb4..012a40c9 100644
--- a/src/presets/docker.vaultsyncignore
+++ b/src/presets/docker.vaultsyncignore
@@ -12,3 +12,4 @@
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/dotnet.vaultsyncignore b/src/presets/dotnet.vaultsyncignore
index c73afee7..1dd7cd9a 100644
--- a/src/presets/dotnet.vaultsyncignore
+++ b/src/presets/dotnet.vaultsyncignore
@@ -28,3 +28,4 @@ coverage/**
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/src/presets/fl_studio.vaultsyncignore b/src/presets/fl_studio.vaultsyncignore
index 50934210..c87758a9 100644
--- a/src/presets/fl_studio.vaultsyncignore
+++ b/src/presets/fl_studio.vaultsyncignore
@@ -4,6 +4,7 @@ Rendered/**
**/Rendered/**
*.fst
*.tmp
+.git/
*.bak
.DS_Store
diff --git a/src/presets/flutter.vaultsyncignore b/src/presets/flutter.vaultsyncignore
index 24b9ae6f..ab8130c2 100644
--- a/src/presets/flutter.vaultsyncignore
+++ b/src/presets/flutter.vaultsyncignore
@@ -15,3 +15,4 @@ build/**
# OS
.DS_Store
+.git/
diff --git a/src/presets/gamemaker.vaultsyncignore b/src/presets/gamemaker.vaultsyncignore
index 113b25ad..6eaa42aa 100644
--- a/src/presets/gamemaker.vaultsyncignore
+++ b/src/presets/gamemaker.vaultsyncignore
@@ -19,3 +19,4 @@ cache/
# OS
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/go.vaultsyncignore b/src/presets/go.vaultsyncignore
index 0eee404c..0b9ef957 100644
--- a/src/presets/go.vaultsyncignore
+++ b/src/presets/go.vaultsyncignore
@@ -12,3 +12,4 @@ pkg/**
# OS
.DS_Store
+.git/
diff --git a/src/presets/godot.vaultsyncignore b/src/presets/godot.vaultsyncignore
index de981c51..d61e99db 100644
--- a/src/presets/godot.vaultsyncignore
+++ b/src/presets/godot.vaultsyncignore
@@ -18,3 +18,4 @@ export_presets.cfg
# OS
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/java.vaultsyncignore b/src/presets/java.vaultsyncignore
index 939e0615..ee84e66a 100644
--- a/src/presets/java.vaultsyncignore
+++ b/src/presets/java.vaultsyncignore
@@ -17,3 +17,4 @@ build/**
# OS
.DS_Store
+.git/
diff --git a/src/presets/jetbrains.vaultsyncignore b/src/presets/jetbrains.vaultsyncignore
index b6a29eff..f652cd1f 100644
--- a/src/presets/jetbrains.vaultsyncignore
+++ b/src/presets/jetbrains.vaultsyncignore
@@ -20,3 +20,4 @@ out/**
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/src/presets/kubernetes.vaultsyncignore b/src/presets/kubernetes.vaultsyncignore
index bc978e04..a6352517 100644
--- a/src/presets/kubernetes.vaultsyncignore
+++ b/src/presets/kubernetes.vaultsyncignore
@@ -3,3 +3,4 @@
*.bak
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/maya.vaultsyncignore b/src/presets/maya.vaultsyncignore
index 97647b46..22f362c9 100644
--- a/src/presets/maya.vaultsyncignore
+++ b/src/presets/maya.vaultsyncignore
@@ -7,3 +7,4 @@ images/**
# OS
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/node.vaultsyncignore b/src/presets/node.vaultsyncignore
index 3c653cba..e0860b36 100644
--- a/src/presets/node.vaultsyncignore
+++ b/src/presets/node.vaultsyncignore
@@ -39,3 +39,4 @@ lerna-debug.log*
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/src/presets/presets.index.json b/src/presets/presets.index.json
index 2bcd05bc..25eaa9ac 100644
--- a/src/presets/presets.index.json
+++ b/src/presets/presets.index.json
@@ -106,7 +106,7 @@
"name": "Avalonia",
"category": "Mobile / UI",
"file": "avalonia.vaultsyncignore",
- "description": "Excludes build output, test artefacts, caches, local tooling state, and IDE junk while preserving repository history."
+ "description": "Excludes build output, test artefacts, caches, local tooling state, IDE junk, and live Git internals while preserving shareable repository files."
},
{
diff --git a/src/presets/python.vaultsyncignore b/src/presets/python.vaultsyncignore
index c482906b..563c7349 100644
--- a/src/presets/python.vaultsyncignore
+++ b/src/presets/python.vaultsyncignore
@@ -45,3 +45,4 @@ venv/**
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/src/presets/react_native.vaultsyncignore b/src/presets/react_native.vaultsyncignore
index f496c91e..f018d2d8 100644
--- a/src/presets/react_native.vaultsyncignore
+++ b/src/presets/react_native.vaultsyncignore
@@ -19,3 +19,4 @@ node_modules/**
# OS
.DS_Store
+.git/
diff --git a/src/presets/rust.vaultsyncignore b/src/presets/rust.vaultsyncignore
index 77b2eafb..658576b2 100644
--- a/src/presets/rust.vaultsyncignore
+++ b/src/presets/rust.vaultsyncignore
@@ -10,3 +10,4 @@ Cargo.lock.bak
# OS
.DS_Store
+.git/
diff --git a/src/presets/terraform.vaultsyncignore b/src/presets/terraform.vaultsyncignore
index 3aa5a53f..1d178559 100644
--- a/src/presets/terraform.vaultsyncignore
+++ b/src/presets/terraform.vaultsyncignore
@@ -9,3 +9,4 @@ crash.*.log
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/unity.vaultsyncignore b/src/presets/unity.vaultsyncignore
index 7b8fe2bc..c8d9c8a7 100644
--- a/src/presets/unity.vaultsyncignore
+++ b/src/presets/unity.vaultsyncignore
@@ -70,3 +70,4 @@ Thumbs.db
*.old
*.swp
*.lock
+.git/
diff --git a/src/presets/unreal.vaultsyncignore b/src/presets/unreal.vaultsyncignore
index e81c5ecc..f97a023c 100644
--- a/src/presets/unreal.vaultsyncignore
+++ b/src/presets/unreal.vaultsyncignore
@@ -28,3 +28,4 @@ Thumbs.db
*.old
# Git / metadata
+.git/
diff --git a/src/presets/video.vaultsyncignore b/src/presets/video.vaultsyncignore
index f962cea4..1ba598eb 100644
--- a/src/presets/video.vaultsyncignore
+++ b/src/presets/video.vaultsyncignore
@@ -12,3 +12,4 @@ RenderCache/**
.DS_Store
Thumbs.db
+.git/
diff --git a/src/presets/vscode.vaultsyncignore b/src/presets/vscode.vaultsyncignore
index 35ff027b..9ba6cb75 100644
--- a/src/presets/vscode.vaultsyncignore
+++ b/src/presets/vscode.vaultsyncignore
@@ -10,3 +10,4 @@
.DS_Store
._*
Thumbs.db
+.git/
diff --git a/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs b/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
index 25f6a647..39a2ab3c 100644
--- a/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
+++ b/tests/VaultSync.Core.Tests/BackupSafetyServiceTests.cs
@@ -276,7 +276,7 @@ public void FilterService_NestedGeneratedOutputPatternsExcludeExpectedFiles(stri
[InlineData("python", "module/__pycache__/tool.cpython-310.pyc")]
[InlineData("unity", "Library/metadata/cache.bin")]
[InlineData("unreal", "Intermediate/Build/cache.bin")]
- public void BuiltInSourcePresets_ExcludeGeneratedOutputsButKeepRepoMetadata(string preset, string generatedPath)
+ public void BuiltInSourcePresets_ExcludeGeneratedOutputsAndLiveGitInternals(string preset, string generatedPath)
{
var projectRoot = Path.Combine(_tempDir.Path, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(projectRoot);
@@ -297,8 +297,8 @@ public void BuiltInSourcePresets_ExcludeGeneratedOutputsButKeepRepoMetadata(stri
Assert.Contains(".github/workflows/ci.yml", entries);
Assert.Contains(".gitignore", entries);
Assert.Contains(".gitattributes", entries);
- Assert.Contains(".git/refs/heads/local-feature", entries);
- Assert.Contains(".git/objects/aa/object", entries);
+ Assert.DoesNotContain(".git/refs/heads/local-feature", entries);
+ Assert.DoesNotContain(".git/objects/aa/object", entries);
Assert.DoesNotContain(entries, path => path.Equals(generatedPath, StringComparison.OrdinalIgnoreCase));
}
@@ -377,7 +377,6 @@ public void PresetCatalog_IsCompleteAndUsesSupportedExclusionRules()
{
string[] rules = File.ReadAllLines(Path.Combine(presetsDir, presetFile));
Assert.DoesNotContain(rules, rule => rule.TrimStart().StartsWith('!'));
- Assert.DoesNotContain(rules, rule => rule.Trim().Equals(".git/", StringComparison.OrdinalIgnoreCase));
}
}
From 13ed4d2a1439822396ea0e44718b3b5187afab0f Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:30:43 +0200
Subject: [PATCH 32/54] Fix duplicate metadata preview counts
---
CHANGELOG.md | 1 +
ROADMAP.md | 5 +
docs/RELEASE_1.8.7.md | 5 +-
.../Services/MetadataSyncService.cs | 30 +++-
.../VaultSync.Core.Tests/MetadataSyncTests.cs | 131 ++++++++++++++++++
5 files changed, 169 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31194468..57782517 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
- [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.
- [BUG-18104] Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
+- [BUG-18105] Prevented metadata-import previews from double-counting projects and backups that are represented by both portable metadata and legacy repository folders.
## [1.8.6] - 10.08.2026
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 15869adf..d82d35cc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -514,6 +514,11 @@ that exists only on the release branch or remains planned.
`VS-1801`, unsupported negation rules are absent, and every backup path uses
the shared preset resolver with regression coverage.
- Completed on the 1.8.7 release branch on 2026-08-13 in PR #546.
+- [x] `BUG-18105` `P1` Prevent metadata-import previews from double-counting
+ projects and backups represented by both metadata and legacy folders.
+ - Acceptance: a preview reports each project, snapshot, and backup once,
+ remains read-only, and repeated previews return the same result.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
### Delivery sequence
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 9eea6868..8bc654b7 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-13
+## Status as of 2026-08-15
### Implemented on the release branch
@@ -61,6 +61,9 @@ assumed and must be approved explicitly if the release needs one.
state without relying on unsupported negation rules; Windows Robocopy also
consumes the shared preset resolver. Live `.git` internals remain excluded
pending the separately gated full-repository mode (`BUG-18104`, `VS-1801`).
+- Metadata-import previews now deduplicate portable metadata and corresponding
+ legacy repository folders, count each proposed change once, and remain
+ repeatable without mutating the local repository (`BUG-18105`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs
index 8562c03e..179a4e94 100644
--- a/src/VaultSync.Core/Services/MetadataSyncService.cs
+++ b/src/VaultSync.Core/Services/MetadataSyncService.cs
@@ -1142,7 +1142,24 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta
tombstones.SnapshotIds,
backups.LiveSnapshotIds);
- MetadataSyncPreview filesystemPreview = PreviewBackupFoldersFromDestination(rootPath, opts, store.DatabasePath);
+ HashSet metadataProjectNames = metaProjects
+ .Where(project => !string.IsNullOrWhiteSpace(project.Name))
+ .Select(project => project.Name)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ HashSet metadataBackupPaths = metaBackups
+ .Where(backup =>
+ !string.IsNullOrWhiteSpace(backup.PathRel) &&
+ !tombstones.BackupIds.Contains(backup.ExternalId) &&
+ TryResolveBackupPath(rootPath, backup.PathRel, out _))
+ .Select(backup => NormalizeStablePath(NormalizeBackupPathRel(backup.PathRel)))
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ MetadataSyncPreview filesystemPreview = PreviewBackupFoldersFromDestination(
+ rootPath,
+ opts,
+ store.DatabasePath,
+ metadataProjectNames,
+ metadataBackupPaths);
int addProjects = projectCounts.Add + filesystemPreview.NewProjects;
addSnapshots += filesystemPreview.NewSnapshots;
int addBackups = backups.Add + filesystemPreview.NewBackups;
@@ -1500,7 +1517,12 @@ private int ResolveLegacySnapshot(
totalBytes: sizeBytes);
}
- private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath, MetadataSyncOptions opts, string databasePath)
+ private MetadataSyncPreview PreviewBackupFoldersFromDestination(
+ string rootPath,
+ MetadataSyncOptions opts,
+ string databasePath,
+ IReadOnlySet? representedProjectNames = null,
+ IReadOnlySet? representedBackupPaths = null)
{
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
@@ -1517,6 +1539,8 @@ private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath,
.GetAllProjects()
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (representedProjectNames is not null)
+ projectsByName.UnionWith(representedProjectNames);
IReadOnlyDictionary snapshotExternalMap = _repo.GetSnapshotExternalIdMap();
IReadOnlyDictionary backupExternalMap = _repo.GetBackupExternalIdMap();
var existingBackupPaths = _repo
@@ -1525,6 +1549,8 @@ private MetadataSyncPreview PreviewBackupFoldersFromDestination(string rootPath,
.Select(backup => NormalizeStablePath(NormalizeBackupPathRel(backup.Path)))
.Where(path => !string.IsNullOrWhiteSpace(path))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (representedBackupPaths is not null)
+ existingBackupPaths.UnionWith(representedBackupPaths);
int addProjects = 0;
int addSnapshots = 0;
diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
index abe178c7..e2bddc18 100644
--- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
+++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
@@ -45,6 +45,137 @@ public async System.Threading.Tasks.Task PreviewImportFromStoreAsync_WithInvalid
Assert.Equal(MetadataSyncStatus.NoStore, preview.Status);
}
+ [Fact]
+ public void PreviewImportFromStore_CountsChangesWithoutMutatingTheRepository()
+ {
+ string metaRoot = CreateTempDir();
+ string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db");
+ string remoteBackupPath = Path.Combine("remote-project", "2026-08-15_10-00-00");
+ Directory.CreateDirectory(Path.Combine(metaRoot, remoteBackupPath));
+
+ SqliteRepository repo = CreateRepository(dbPath);
+ int localProjectId = repo.AddProject(new Project
+ {
+ ExternalId = "local-project",
+ Name = "Local Project",
+ RootPath = CreateTempDir(),
+ Preset = "dotnet",
+ CreatedUtc = DateTime.UtcNow.AddDays(-2)
+ });
+ int localSnapshotId = repo.CreateSnapshotFromMetadata(
+ "local-snapshot",
+ localProjectId,
+ DateTime.UtcNow.AddDays(-1),
+ fileCount: 1,
+ totalBytes: 64);
+ repo.CreateBackupFromMetadata(
+ "deleted-by-tombstone",
+ localProjectId,
+ localSnapshotId,
+ DateTime.UtcNow.AddHours(-2),
+ "manual",
+ 64,
+ "local/tombstoned",
+ metaRoot,
+ "Primary",
+ isProtected: false,
+ isImported: false);
+ repo.CreateBackupFromMetadata(
+ "missing-remote-path",
+ localProjectId,
+ localSnapshotId,
+ DateTime.UtcNow.AddHours(-1),
+ "manual",
+ 64,
+ "local/missing",
+ metaRoot,
+ "Primary",
+ isProtected: false,
+ isImported: false);
+
+ MetadataStore store = CreateStore(metaRoot);
+ SeedMetaInfo(store, "remote-machine");
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = "remote-project",
+ Name = "Remote Project",
+ Preset = "node",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-3),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = "remote-snapshot",
+ ProjectExternalId = "remote-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-3),
+ FileCount = 3,
+ TotalBytes = 512
+ });
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = "dead-snapshot",
+ ProjectExternalId = "remote-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-4),
+ FileCount = 1,
+ TotalBytes = 32
+ });
+ store.UpsertBackup(new MetaBackup
+ {
+ ExternalId = "remote-backup",
+ ProjectExternalId = "remote-project",
+ SnapshotExternalId = "remote-snapshot",
+ CreatedUtc = DateTime.UtcNow.AddHours(-2),
+ Type = "manual",
+ TotalBytes = 512,
+ PathRel = remoteBackupPath,
+ DestinationAlias = "Primary",
+ KdfParamsJson = "{}"
+ });
+ store.UpsertBackup(new MetaBackup
+ {
+ ExternalId = "missing-remote-path",
+ ProjectExternalId = "remote-project",
+ SnapshotExternalId = "dead-snapshot",
+ CreatedUtc = DateTime.UtcNow.AddHours(-1),
+ Type = "manual",
+ TotalBytes = 32,
+ PathRel = "../outside-repository",
+ DestinationAlias = "Primary",
+ KdfParamsJson = "{}"
+ });
+ store.AddTombstone(new MetaTombstone
+ {
+ EntityType = "backup",
+ EntityId = "deleted-by-tombstone",
+ DeletedUtc = DateTime.UtcNow,
+ OriginMachineId = "remote-machine"
+ });
+ store.AddTombstone(new MetaTombstone
+ {
+ EntityType = "snapshot",
+ EntityId = "dead-snapshot",
+ DeletedUtc = DateTime.UtcNow,
+ OriginMachineId = "remote-machine"
+ });
+
+ var service = new MetadataSyncService(repo);
+ MetadataSyncPreview preview = service.PreviewImportFromStore(metaRoot, MetadataSyncOptions.Default);
+ MetadataSyncPreview cachedPreview = service.PreviewImportFromStore(metaRoot, MetadataSyncOptions.Default);
+
+ Assert.Equal(MetadataSyncStatus.Success, preview.Status);
+ Assert.Equal(1, preview.NewProjects);
+ Assert.Equal(0, preview.LinkedProjects);
+ Assert.Equal(1, preview.NewSnapshots);
+ Assert.Equal(1, preview.NewBackups);
+ Assert.Equal(2, preview.DeletedBackups);
+ Assert.Equal(preview, cachedPreview);
+ Assert.Null(repo.GetProjectByExternalId("remote-project"));
+ Assert.NotNull(repo.GetBackupByExternalId("deleted-by-tombstone"));
+ Assert.NotNull(repo.GetBackupByExternalId("missing-remote-path"));
+ }
+
[Fact]
public async System.Threading.Tasks.Task ExportBackupTombstoneToStoreAsync_PersistsTombstoneAndReleasesItsPerRootGate()
{
From 0304ff68a8398ba033166b002eaa26fa1f4e51d4 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:30:43 +0200
Subject: [PATCH 33/54] Update test infrastructure dependencies
---
Directory.Packages.props | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5d2569ef..d3e18d9d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,9 +40,9 @@
-
+
-
-
+
+
From 064f468bff9f06fa04ac1fa5c0022033e1209edb Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:47:24 +0200
Subject: [PATCH 34/54] Resolve Sonar findings and expand regression coverage
---
CHANGELOG.md | 1 +
ROADMAP.md | 5 +
docs/RELEASE_1.8.7.md | 3 +
.../Services/MetadataSyncService.cs | 67 ++++--
.../Services/NetworkMountService.cs | 16 +-
.../Services/RepositoryLeaseService.cs | 4 +-
src/VaultSync.Core/Services/RobocopyRunner.cs | 67 +++---
.../Services/GitHubUpdateService.cs | 4 +-
src/VaultSync.UI/Services/ThemeColor.cs | 2 +-
.../VaultSync.Core.Tests/DestinationTests.cs | 84 ++++++++
.../VaultSync.Core.Tests/MetadataSyncTests.cs | 196 +++++++++++++++++-
.../ReleaseManifestVerifierTests.cs | 89 ++++++++
.../RobocopyRunnerTests.cs | 29 +++
13 files changed, 493 insertions(+), 74 deletions(-)
create mode 100644 tests/VaultSync.Core.Tests/RobocopyRunnerTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57782517..aa881283 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@
- [BUG-18102] Disabled connection pooling for the repository coordination database so disposed writer leases release their file handles predictably on Windows.
- [BUG-18104] Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
- [BUG-18105] Prevented metadata-import previews from double-counting projects and backups that are represented by both portable metadata and legacy repository folders.
+- [BUG-18106] Normalized macOS SMB mount diagnostics to remove the complete credential-bearing share identity before masking any remaining raw or escaped password text.
## [1.8.6] - 10.08.2026
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index d82d35cc..c60a496a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -519,6 +519,11 @@ that exists only on the release branch or remains planned.
- Acceptance: a preview reports each project, snapshot, and backup once,
remains read-only, and repeated previews return the same result.
- Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
+- [x] `BUG-18106` `P1` Normalize credential-free macOS SMB mount diagnostics.
+ - Acceptance: mount errors contain neither raw nor URL-escaped passwords,
+ replace known credential-bearing share URLs with their credential-free
+ display identity, and preserve unrelated diagnostic text.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
### Delivery sequence
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 8bc654b7..7bb3469c 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -64,6 +64,9 @@ assumed and must be approved explicitly if the release needs one.
- Metadata-import previews now deduplicate portable metadata and corresponding
legacy repository folders, count each proposed change once, and remain
repeatable without mutating the local repository (`BUG-18105`).
+- macOS SMB mount errors now normalize the complete credential-bearing share
+ identity before masking any remaining raw or escaped password text, producing
+ clean credential-free diagnostics (`BUG-18106`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs
index 179a4e94..a3cbb369 100644
--- a/src/VaultSync.Core/Services/MetadataSyncService.cs
+++ b/src/VaultSync.Core/Services/MetadataSyncService.cs
@@ -1010,7 +1010,7 @@ private static bool HasLocalChangesNewerThan(string rootPath, DateTime importedL
return false;
}
- private static IEnumerable GetTraversableDirectories(string path)
+ private static List GetTraversableDirectories(string path)
{
IEnumerable directories;
try
@@ -1185,7 +1185,7 @@ private MetadataSyncPreview PreviewImportFromStoreInternal(string rootPath, Meta
private static PreviewProjectCounts CountPreviewProjects(
IEnumerable projects,
- IDictionary projectMap,
+ Dictionary projectMap,
IReadOnlyCollection localProjects,
bool allowCreateProjects)
{
@@ -1240,41 +1240,62 @@ private static PreviewTombstoneAnalysis AnalyzePreviewTombstones(
private static PreviewBackupAnalysis AnalyzePreviewBackups(
IEnumerable backups,
string rootPath,
- IReadOnlyDictionary projectMap,
+ Dictionary projectMap,
IReadOnlyDictionary backupExternalMap,
- ISet tombstonedBackupIds)
+ HashSet tombstonedBackupIds)
{
var liveSnapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase);
int add = 0;
int delete = 0;
foreach (MetaBackup backup in backups)
{
- if (string.IsNullOrWhiteSpace(backup.ExternalId))
- continue;
-
- 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 _))
- {
- tombstonedBackupIds.Add(backup.ExternalId);
- delete += backupExternalMap.ContainsKey(backup.ExternalId) ? 1 : 0;
- continue;
- }
- if (projectMap.ContainsKey(backup.ProjectExternalId) && !backupExternalMap.ContainsKey(backup.ExternalId))
- add++;
+ (int backupAdd, int backupDelete) = AnalyzePreviewBackup(
+ backup,
+ rootPath,
+ projectMap,
+ backupExternalMap,
+ tombstonedBackupIds,
+ liveSnapshotIds);
+ add += backupAdd;
+ delete += backupDelete;
}
return new PreviewBackupAnalysis(liveSnapshotIds, add, delete);
}
+ private static (int Add, int Delete) AnalyzePreviewBackup(
+ MetaBackup backup,
+ string rootPath,
+ Dictionary projectMap,
+ IReadOnlyDictionary backupExternalMap,
+ HashSet tombstonedBackupIds,
+ HashSet liveSnapshotIds)
+ {
+ if (string.IsNullOrWhiteSpace(backup.ExternalId))
+ return default;
+
+ bool isTombstoned = tombstonedBackupIds.Contains(backup.ExternalId);
+ if (!string.IsNullOrWhiteSpace(backup.SnapshotExternalId) && !isTombstoned)
+ liveSnapshotIds.Add(backup.SnapshotExternalId);
+ if (isTombstoned)
+ return default;
+ if (!TryResolveBackupPath(rootPath, backup.PathRel, out _))
+ {
+ tombstonedBackupIds.Add(backup.ExternalId);
+ return (0, backupExternalMap.ContainsKey(backup.ExternalId) ? 1 : 0);
+ }
+
+ bool isNew = projectMap.ContainsKey(backup.ProjectExternalId) &&
+ !backupExternalMap.ContainsKey(backup.ExternalId);
+ return (isNew ? 1 : 0, 0);
+ }
+
private static int CountPreviewSnapshots(
IEnumerable snapshots,
- IReadOnlyDictionary projectMap,
+ Dictionary projectMap,
IReadOnlyDictionary snapshotExternalMap,
- ISet tombstonedSnapshotIds,
- IReadOnlySet liveSnapshotIds)
+ HashSet tombstonedSnapshotIds,
+ HashSet liveSnapshotIds)
{
int add = 0;
foreach (MetaSnapshot snapshot in snapshots.Where(snapshot => !string.IsNullOrWhiteSpace(snapshot.ExternalId)))
@@ -1376,7 +1397,7 @@ private void ImportLegacyProjectGroup(
private static bool IsRepairableLegacyBackup(
LegacyBackupFolder folder,
- IReadOnlyDictionary existingBackupByPath) =>
+ Dictionary existingBackupByPath) =>
existingBackupByPath.TryGetValue(NormalizeStablePath(folder.RelativePath), out Backup? backup) &&
backup.IsImported &&
backup.TotalBytes <= 0;
diff --git a/src/VaultSync.Core/Services/NetworkMountService.cs b/src/VaultSync.Core/Services/NetworkMountService.cs
index 8f169fb9..8ecd9e35 100644
--- a/src/VaultSync.Core/Services/NetworkMountService.cs
+++ b/src/VaultSync.Core/Services/NetworkMountService.cs
@@ -468,10 +468,15 @@ private static DestinationResolution CreateMacMountResolution(
$"Mounted {DisplayName(destination)}");
}
- private static string SanitizeMountError(string stderr, string? password, string share, string shareDisplay)
+ internal static string SanitizeMountError(string stderr, string? password, string share, string shareDisplay)
{
string sanitized = stderr ?? string.Empty;
+ if (!string.IsNullOrWhiteSpace(share))
+ {
+ sanitized = sanitized.Replace(share, shareDisplay, StringComparison.OrdinalIgnoreCase);
+ }
+
if (!string.IsNullOrWhiteSpace(password))
{
sanitized = sanitized.Replace(password, "******", StringComparison.Ordinal);
@@ -479,11 +484,6 @@ private static string SanitizeMountError(string stderr, string? password, string
sanitized = sanitized.Replace(escaped, "******", StringComparison.Ordinal);
}
- if (!string.IsNullOrWhiteSpace(share))
- {
- sanitized = sanitized.Replace(share, shareDisplay, StringComparison.OrdinalIgnoreCase);
- }
-
return sanitized;
}
@@ -809,7 +809,7 @@ internal static bool TryParseMacSmbMountLine(
return !string.IsNullOrWhiteSpace(source) && !string.IsNullOrWhiteSpace(mountPoint);
}
- private static string AppendShareSubPath(string mountPoint, string subPath)
+ internal static string AppendShareSubPath(string mountPoint, string subPath)
{
if (string.IsNullOrWhiteSpace(subPath))
return mountPoint;
@@ -862,7 +862,7 @@ private static void Log(string message)
RuntimeVaultLogger.Instance.Info($"[NetworkMount] {message}");
}
- private static string Slugify(string input)
+ internal static string Slugify(string input)
{
var sb = new StringBuilder();
foreach (char ch in input)
diff --git a/src/VaultSync.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs
index ab1017a5..8708d7c4 100644
--- a/src/VaultSync.Core/Services/RepositoryLeaseService.cs
+++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs
@@ -347,7 +347,7 @@ inspection.Lease is not null &&
string.Equals(inspection.Lease.Nonce, nonce, StringComparison.Ordinal);
}
- internal bool TryRelease(string rootPath, string installationId, string nonce)
+ internal static bool TryRelease(string rootPath, string installationId, string nonce)
{
string databasePath = GetDatabasePath(rootPath);
if (!File.Exists(databasePath) || IsLinkedFile(databasePath))
@@ -911,6 +911,6 @@ public void Dispose()
return;
_heartbeatTimer.Dispose();
- _service.TryRelease(_rootPath, Lease.InstallationId, Lease.Nonce);
+ RepositoryLeaseService.TryRelease(_rootPath, Lease.InstallationId, Lease.Nonce);
}
}
diff --git a/src/VaultSync.Core/Services/RobocopyRunner.cs b/src/VaultSync.Core/Services/RobocopyRunner.cs
index 0d7cb8e2..c724b8c2 100644
--- a/src/VaultSync.Core/Services/RobocopyRunner.cs
+++ b/src/VaultSync.Core/Services/RobocopyRunner.cs
@@ -283,48 +283,43 @@ private static void MergeIgnorePatterns(IEnumerable patterns, List treat as directory pattern
- // - everything else => file/glob pattern
- // - strip leading "./"
- if (line.StartsWith("./")) line = line.Substring(2);
+ (isDirectory ? dirs : files).Add(pattern);
+ }
+ }
- if (line.EndsWith("/"))
- {
- // robocopy /XD likes bare dir names or relative paths; leave as-is
- string d = line.TrimEnd('/');
- if (!string.IsNullOrWhiteSpace(d))
- dirs.Add(NormalizeRobocopyGlob(d));
- }
- else
+ internal static bool TryNormalizeIgnorePattern(string raw, out string pattern, out bool isDirectory)
+ {
+ string line = raw.Trim();
+ pattern = string.Empty;
+ isDirectory = false;
+ if (line.Length == 0 || line.StartsWith('#'))
+ return false;
+
+ // Robocopy's /XD is recursive already, so reduce **/bin/** to bin.
+ if (line.EndsWith("/**", StringComparison.Ordinal) ||
+ line.EndsWith("\\**", StringComparison.Ordinal))
+ {
+ string directory = line[..^3].TrimEnd('/', '\\');
+ if (directory.StartsWith("**/", StringComparison.Ordinal) ||
+ directory.StartsWith("**\\", StringComparison.Ordinal))
{
- files.Add(NormalizeRobocopyGlob(line));
+ directory = directory[3..];
}
+
+ pattern = NormalizeRobocopyGlob(directory);
+ isDirectory = true;
+ return pattern.Length > 0;
}
+
+ if (line.StartsWith("./", StringComparison.Ordinal))
+ line = line[2..];
+
+ isDirectory = line.EndsWith('/');
+ pattern = NormalizeRobocopyGlob(isDirectory ? line.TrimEnd('/') : line);
+ return pattern.Length > 0;
}
private static string NormalizeRobocopyGlob(string pattern)
diff --git a/src/VaultSync.UI/Services/GitHubUpdateService.cs b/src/VaultSync.UI/Services/GitHubUpdateService.cs
index ce8f05f4..8782f2e1 100644
--- a/src/VaultSync.UI/Services/GitHubUpdateService.cs
+++ b/src/VaultSync.UI/Services/GitHubUpdateService.cs
@@ -509,7 +509,7 @@ manifestUri is null ||
}
}
- private static (string? ManifestUrl, string? ManifestSha256, long ManifestSize, Uri? ArchiveUrl, string? ArchiveName, string? ArchiveSha256, long ArchiveSize) GetPatchAssets(IReadOnlyCollection assets)
+ internal 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);
@@ -546,7 +546,7 @@ private static (string? ManifestUrl, string? ManifestSha256, long ManifestSize,
return (null, null, 0, null, null, null, 0);
}
- private static (Uri? InstallerUrl, string? InstallerName, string? InstallerSha256, long InstallerSize) GetInstallerAsset(IReadOnlyCollection assets)
+ internal static (Uri? InstallerUrl, string? InstallerName, string? InstallerSha256, long InstallerSize) GetInstallerAsset(IReadOnlyCollection assets)
{
if (assets.Count == 0)
return (null, null, null, 0);
diff --git a/src/VaultSync.UI/Services/ThemeColor.cs b/src/VaultSync.UI/Services/ThemeColor.cs
index cb9c34da..217a8412 100644
--- a/src/VaultSync.UI/Services/ThemeColor.cs
+++ b/src/VaultSync.UI/Services/ThemeColor.cs
@@ -13,7 +13,7 @@ public static string NormalizeHex(string? value, string fallback)
return fallback;
string candidate = value.Trim();
- if (!candidate.StartsWith("#", StringComparison.Ordinal))
+ if (!candidate.StartsWith('#'))
candidate = "#" + candidate;
return Color.TryParse(candidate, out Color color)
diff --git a/tests/VaultSync.Core.Tests/DestinationTests.cs b/tests/VaultSync.Core.Tests/DestinationTests.cs
index d7cc0ef7..d331c37d 100644
--- a/tests/VaultSync.Core.Tests/DestinationTests.cs
+++ b/tests/VaultSync.Core.Tests/DestinationTests.cs
@@ -60,6 +60,53 @@ public void TryParseMacSmbMountLine_RejectsUnrelatedOrMalformedLines(string line
Assert.False(NetworkMountService.TryParseMacSmbMountLine(line, out _, out _));
}
+ [Theory]
+ [InlineData("")]
+ [InlineData("server")]
+ [InlineData("smb://server")]
+ public void TryParseShareWithSubpath_RejectsIncompleteShares(string path)
+ {
+ Assert.False(NetworkMountService.TryParseShareWithSubpath(path, out _, out _, out _));
+ }
+
+ [Fact]
+ public void SanitizeMountError_RemovesRawAndEscapedCredentials()
+ {
+ const string password = "p@ss word";
+ const string share = "//user:p%40ss%20word@server/share";
+ const string display = "//user@server/share";
+ string stderr = $"failed {share}; raw={password}";
+
+ string sanitized = NetworkMountService.SanitizeMountError(stderr, password, share, display);
+
+ Assert.DoesNotContain(password, sanitized, StringComparison.Ordinal);
+ Assert.DoesNotContain(Uri.EscapeDataString(password), sanitized, StringComparison.Ordinal);
+ Assert.Contains(display, sanitized, StringComparison.Ordinal);
+ Assert.Equal("unchanged", NetworkMountService.SanitizeMountError("unchanged", null, "", ""));
+ }
+
+ [Theory]
+ [InlineData("", "share")]
+ [InlineData(" / ", "share")]
+ [InlineData("folder/child", "share/folder/child")]
+ [InlineData("\\folder\\child", "share/folder/child")]
+ public void AppendShareSubPath_NormalizesPortableSegments(string subPath, string expectedRelative)
+ {
+ string mountPoint = Path.Combine(Path.GetTempPath(), "share");
+ string expected = Path.Combine(Path.GetTempPath(), expectedRelative.Replace('/', Path.DirectorySeparatorChar));
+
+ Assert.Equal(expected, NetworkMountService.AppendShareSubPath(mountPoint, subPath));
+ }
+
+ [Theory]
+ [InlineData("Team Archive", "team-archive")]
+ [InlineData("Build_01!", "build_01")]
+ [InlineData(" !!! ", "vaultsync-share")]
+ public void Slugify_ProducesStableMountNames(string input, string expected)
+ {
+ Assert.Equal(expected, NetworkMountService.Slugify(input));
+ }
+
[Fact]
public void PrepareDestination_WithLocalPath_ReturnsSuccessAndEffectivePath()
{
@@ -81,6 +128,43 @@ public void PrepareDestination_WithLocalPath_ReturnsSuccessAndEffectivePath()
Assert.Contains("local path", result.Message, StringComparison.OrdinalIgnoreCase);
}
+ [Fact]
+ public void PrepareDestination_WithInvalidLocalPath_ReturnsFailure()
+ {
+ var destination = new BackupDestination
+ {
+ Alias = "Invalid local",
+ Path = "invalid\0path",
+ Active = true
+ };
+
+ DestinationResolution result = new NetworkMountService().PrepareDestination(destination, null);
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("Invalid local", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PrepareDestination_PreMountedPathRequiresAnExistingDirectory()
+ {
+ using var tempDir = new TempDirectory();
+ var reachable = new BackupDestination { Path = tempDir.Path, PreMounted = true, Active = true };
+ var missing = new BackupDestination
+ {
+ Alias = "Missing mount",
+ Path = Path.Combine(tempDir.Path, "missing"),
+ PreMounted = true,
+ Active = true
+ };
+ var service = new NetworkMountService();
+
+ Assert.True(service.PrepareDestination(reachable, null).IsSuccess);
+ DestinationResolution failure = service.PrepareDestination(missing, null);
+ Assert.False(failure.IsSuccess);
+ Assert.Contains("not accessible", failure.Message, StringComparison.OrdinalIgnoreCase);
+ NetworkMountService.Cleanup(failure);
+ }
+
[Fact]
public void PrepareDestination_WithUnreachableNetworkShareAndAutoMountDisabled_ReturnsFailure()
{
diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
index e2bddc18..c638fdf4 100644
--- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
+++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
@@ -176,6 +176,198 @@ public void PreviewImportFromStore_CountsChangesWithoutMutatingTheRepository()
Assert.NotNull(repo.GetBackupByExternalId("missing-remote-path"));
}
+ [Fact]
+ public void PreviewImportFromStore_LinksExistingProjectsAndRejectsUnimportableRecords()
+ {
+ string metaRoot = CreateTempDir();
+ string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db");
+ string linkedBackupPath = Path.Combine("linked-project", "2026-08-15_11-00-00");
+ string existingBackupPath = Path.Combine("existing-project", "2026-08-15_09-00-00");
+ string orphanBackupPath = Path.Combine("orphan-project", "2026-08-15_08-00-00");
+ Directory.CreateDirectory(Path.Combine(metaRoot, linkedBackupPath));
+ Directory.CreateDirectory(Path.Combine(metaRoot, existingBackupPath));
+ Directory.CreateDirectory(Path.Combine(metaRoot, orphanBackupPath));
+ Directory.CreateDirectory(Path.Combine(metaRoot, "legacy-only", "2026-08-15_07-00-00"));
+
+ SqliteRepository repo = CreateRepository(dbPath);
+ int linkedProjectId = repo.AddProject(new Project
+ {
+ ExternalId = string.Empty,
+ Name = "Linked Project",
+ RootPath = CreateTempDir(),
+ Preset = "dotnet",
+ CreatedUtc = DateTime.UtcNow.AddDays(-4)
+ });
+ using (var connection = new SqliteConnection($"Data Source={dbPath}"))
+ {
+ connection.Open();
+ using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = "UPDATE projects SET external_id = '' WHERE id = $id;";
+ command.Parameters.AddWithValue("$id", linkedProjectId);
+ Assert.Equal(1, command.ExecuteNonQuery());
+ }
+ int existingProjectId = repo.AddProject(new Project
+ {
+ ExternalId = "existing-project",
+ Name = "Existing Project",
+ RootPath = CreateTempDir(),
+ Preset = "node",
+ CreatedUtc = DateTime.UtcNow.AddDays(-3)
+ });
+ int existingSnapshotId = repo.CreateSnapshotFromMetadata(
+ "existing-snapshot",
+ existingProjectId,
+ DateTime.UtcNow.AddDays(-1),
+ fileCount: 2,
+ totalBytes: 128);
+ repo.CreateBackupFromMetadata(
+ "existing-backup",
+ existingProjectId,
+ existingSnapshotId,
+ DateTime.UtcNow.AddHours(-3),
+ "manual",
+ 128,
+ existingBackupPath,
+ metaRoot,
+ "Primary",
+ isProtected: false,
+ isImported: false);
+
+ MetadataStore store = CreateStore(metaRoot);
+ SeedMetaInfo(store, "remote-machine");
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = "linked-project",
+ Name = "Linked Project",
+ Preset = "dotnet",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-5),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = "existing-project",
+ Name = "Existing Project",
+ Preset = "node",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-5),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = "blocked-project",
+ Name = "Blocked Project",
+ Preset = "generic",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-5),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = string.Empty,
+ Name = "Invalid Project",
+ Preset = "generic",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-5),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = "linked-snapshot",
+ ProjectExternalId = "linked-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-4),
+ FileCount = 1,
+ TotalBytes = 64
+ });
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = "existing-snapshot",
+ ProjectExternalId = "existing-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-5),
+ FileCount = 2,
+ TotalBytes = 128
+ });
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = "orphan-snapshot",
+ ProjectExternalId = "missing-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-6),
+ FileCount = 1,
+ TotalBytes = 32
+ });
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = string.Empty,
+ ProjectExternalId = "linked-project",
+ CreatedUtc = DateTime.UtcNow.AddHours(-7),
+ FileCount = 0,
+ TotalBytes = 0
+ });
+
+ AddMetaBackup("linked-backup", "linked-project", "linked-snapshot", linkedBackupPath);
+ AddMetaBackup("existing-backup", "existing-project", "existing-snapshot", existingBackupPath);
+ AddMetaBackup("orphan-backup", "missing-project", "orphan-snapshot", orphanBackupPath);
+ AddMetaBackup("invalid-path", "linked-project", "orphan-snapshot", "../outside-repository");
+ AddMetaBackup(string.Empty, "linked-project", "linked-snapshot", linkedBackupPath);
+ AddMetaBackup("tombstoned-backup", "linked-project", "tombstoned-snapshot", linkedBackupPath);
+ store.AddTombstone(new MetaTombstone
+ {
+ EntityType = "backup",
+ EntityId = "tombstoned-backup",
+ DeletedUtc = DateTime.UtcNow,
+ OriginMachineId = "remote-machine"
+ });
+ store.AddTombstone(new MetaTombstone
+ {
+ EntityType = "other",
+ EntityId = "ignored-tombstone",
+ DeletedUtc = DateTime.UtcNow,
+ OriginMachineId = "remote-machine"
+ });
+ store.AddTombstone(new MetaTombstone
+ {
+ EntityType = "backup",
+ EntityId = string.Empty,
+ DeletedUtc = DateTime.UtcNow,
+ OriginMachineId = "remote-machine"
+ });
+
+ var service = new MetadataSyncService(repo);
+ MetadataSyncPreview preview = service.PreviewImportFromStore(
+ metaRoot,
+ new MetadataSyncOptions(AllowCreateProjects: false, MarkNeedsRestoreOnImport: true));
+
+ Assert.Equal(MetadataSyncStatus.Success, preview.Status);
+ Assert.Equal(0, preview.NewProjects);
+ Assert.Equal(1, preview.LinkedProjects);
+ Assert.Equal(1, preview.NewSnapshots);
+ Assert.Equal(1, preview.NewBackups);
+ Assert.Equal(0, preview.DeletedBackups);
+ Assert.Equal(string.Empty, repo.GetProjectById(linkedProjectId)?.ExternalId);
+ Assert.Null(repo.GetBackupByExternalId("linked-backup"));
+
+ void AddMetaBackup(string externalId, string projectExternalId, string snapshotExternalId, string pathRel)
+ {
+ store.UpsertBackup(new MetaBackup
+ {
+ ExternalId = externalId,
+ ProjectExternalId = projectExternalId,
+ SnapshotExternalId = snapshotExternalId,
+ CreatedUtc = DateTime.UtcNow.AddHours(-2),
+ Type = "manual",
+ TotalBytes = 64,
+ PathRel = pathRel,
+ DestinationAlias = "Primary",
+ KdfParamsJson = "{}"
+ });
+ }
+ }
+
[Fact]
public async System.Threading.Tasks.Task ExportBackupTombstoneToStoreAsync_PersistsTombstoneAndReleasesItsPerRootGate()
{
@@ -1132,9 +1324,9 @@ public void ExportBackupToStore_WritesMetadata()
var backups = store.ListBackups().ToList();
var projects = store.ListProjects().ToList();
- Assert.Single(backups);
+ MetaBackup exportedBackup = Assert.Single(backups);
Assert.Single(projects);
- Assert.Equal("Primary", backups[0].DestinationAlias);
+ Assert.Equal("Primary", exportedBackup.DestinationAlias);
Backup updatedBackup = repo.GetBackupById(backupId);
Assert.False(string.IsNullOrWhiteSpace(updatedBackup?.ExternalId));
diff --git a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
index b4cd6e3f..62c5bad6 100644
--- a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
+++ b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
using System.Text.Json;
using VaultSync.UI.Services;
using Xunit;
@@ -74,6 +76,57 @@ public void AssetsMissingFromGitHub_AreRejected()
CreateManifest(), Tag, prerelease: false, [], out _));
}
+ [Fact]
+ public void PlatformAssets_AreSelectedOnlyFromVerifiedManifestEntries()
+ {
+ List assets = CreatePlatformAssets();
+
+ var patch = GitHubUpdateService.GetPatchAssets(assets);
+ var installer = GitHubUpdateService.GetInstallerAsset(assets);
+
+ Assert.NotNull(patch.ManifestUrl);
+ Assert.NotNull(patch.ArchiveUrl);
+ Assert.Equal(Hash, patch.ManifestSha256);
+ Assert.Equal(Hash, patch.ArchiveSha256);
+ Assert.Equal(10, patch.ManifestSize);
+ Assert.Equal(20, patch.ArchiveSize);
+ Assert.EndsWith(".zip", patch.ArchiveName, StringComparison.OrdinalIgnoreCase);
+ Assert.NotNull(installer.InstallerUrl);
+ Assert.Equal(Hash, installer.InstallerSha256);
+ Assert.Equal(30, installer.InstallerSize);
+
+ if (OperatingSystem.IsWindows())
+ Assert.EndsWith(".exe", installer.InstallerName, StringComparison.OrdinalIgnoreCase);
+ else if (OperatingSystem.IsMacOS())
+ Assert.EndsWith(".dmg", installer.InstallerName, StringComparison.OrdinalIgnoreCase);
+ else
+ Assert.Contains(
+ new[] { ".deb", ".AppImage", ".tar.gz" },
+ extension => installer.InstallerName!.EndsWith(extension, StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void PlatformAssets_RejectUntrustedUrlsAndEmptyCollections()
+ {
+ List untrusted = CreatePlatformAssets()
+ .Select(asset => new ReleaseManifestAsset
+ {
+ Name = asset.Name,
+ Platform = asset.Platform,
+ Architecture = asset.Architecture,
+ PackageKind = asset.PackageKind,
+ SizeBytes = asset.SizeBytes,
+ Sha256 = asset.Sha256,
+ DownloadUrl = $"https://evil.example/{asset.Name}"
+ })
+ .ToList();
+
+ Assert.Null(GitHubUpdateService.GetPatchAssets([]).ArchiveUrl);
+ Assert.Null(GitHubUpdateService.GetInstallerAsset([]).InstallerUrl);
+ Assert.Null(GitHubUpdateService.GetPatchAssets(untrusted).ArchiveUrl);
+ Assert.Null(GitHubUpdateService.GetInstallerAsset(untrusted).InstallerUrl);
+ }
+
private static string CreateManifest()
{
var manifest = new
@@ -117,4 +170,40 @@ private static List CreatePublishedAssets(
100,
$"sha256:{Hash}")
];
+
+ private static List CreatePlatformAssets()
+ {
+ var assets = new List();
+ foreach (string suffix in new[]
+ {
+ "windows", "macos", "macos-apple-silicon", "macos-intel",
+ "linux", "linux-x64", "linux-arm64"
+ })
+ {
+ assets.Add(CreateAsset($"vaultsync-patch-{suffix}.json", 10));
+ assets.Add(CreateAsset($"vaultsync-patch-{suffix}.zip", 20));
+ }
+
+ assets.Add(CreateAsset("VaultSync-1.8.7-setup.exe", 30));
+ assets.Add(CreateAsset("VaultSync-1.8.7-macos.dmg", 30));
+ foreach (string suffix in new[] { "linux-x64", "linux-arm64" })
+ {
+ assets.Add(CreateAsset($"VaultSync-1.8.7-{suffix}.deb", 30));
+ assets.Add(CreateAsset($"VaultSync-1.8.7-{suffix}.AppImage", 30));
+ assets.Add(CreateAsset($"VaultSync-1.8.7-{suffix}.tar.gz", 30));
+ }
+
+ return assets;
+ }
+
+ private static ReleaseManifestAsset CreateAsset(string name, long size) => new()
+ {
+ Name = name,
+ Platform = "test",
+ Architecture = "test",
+ PackageKind = "test",
+ SizeBytes = size,
+ Sha256 = Hash,
+ DownloadUrl = $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{Tag}/{name}"
+ };
}
diff --git a/tests/VaultSync.Core.Tests/RobocopyRunnerTests.cs b/tests/VaultSync.Core.Tests/RobocopyRunnerTests.cs
new file mode 100644
index 00000000..55807565
--- /dev/null
+++ b/tests/VaultSync.Core.Tests/RobocopyRunnerTests.cs
@@ -0,0 +1,29 @@
+using VaultSync.Core.Services;
+using Xunit;
+
+namespace VaultSync.Core.Tests;
+
+public sealed class RobocopyRunnerTests
+{
+ [Theory]
+ [InlineData("", false, false, "")]
+ [InlineData(" # generated output", false, false, "")]
+ [InlineData("**/bin/**", true, true, "bin")]
+ [InlineData("**\\obj\\**", true, true, "obj")]
+ [InlineData("cache/", true, true, "cache")]
+ [InlineData("./*.tmp", true, false, "*.tmp")]
+ [InlineData("generated/**/*.js", true, false, "generated\\*\\*.js")]
+ [InlineData("./", false, false, "")]
+ public void TryNormalizeIgnorePattern_ClassifiesRobocopyExclusions(
+ string raw,
+ bool expectedResult,
+ bool expectedDirectory,
+ string expectedPattern)
+ {
+ bool result = RobocopyRunner.TryNormalizeIgnorePattern(raw, out string pattern, out bool isDirectory);
+
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(expectedDirectory, isDirectory);
+ Assert.Equal(expectedPattern, pattern);
+ }
+}
From 9349e56f7550c41e12f7c4d4d67f12f75f143613 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:55:22 +0200
Subject: [PATCH 35/54] Cover release manifest trust boundaries
---
.../Services/GitHubUpdateService.cs | 113 +++++++-----
.../ReleaseManifestVerifierTests.cs | 168 ++++++++++++++++++
2 files changed, 240 insertions(+), 41 deletions(-)
diff --git a/src/VaultSync.UI/Services/GitHubUpdateService.cs b/src/VaultSync.UI/Services/GitHubUpdateService.cs
index 8782f2e1..d1722372 100644
--- a/src/VaultSync.UI/Services/GitHubUpdateService.cs
+++ b/src/VaultSync.UI/Services/GitHubUpdateService.cs
@@ -432,7 +432,7 @@ private sealed class GitHubRelease
public List? Assets { get; set; }
}
- private sealed class GitHubAsset
+ internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
@@ -452,52 +452,24 @@ private sealed class GitHubAsset
string releaseTag,
CancellationToken cancellationToken)
{
- 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)
- {
+ if (!TryGetReleaseManifestAsset(
+ release.Assets,
+ releaseTag,
+ out GitHubAsset? manifestAsset,
+ out Uri? manifestUri,
+ out string? 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),
+ byte[] bytes = await s_httpClient.GetByteArrayAsync(manifestUri!, cancellationToken).ConfigureAwait(false);
+ return ValidateDownloadedReleaseManifest(
+ bytes,
+ manifestAsset!,
+ expectedHash!,
releaseTag,
release.Prerelease,
- publishedAssets,
- out IReadOnlyDictionary assets)
- ? assets.Values.ToList()
- : null;
+ release.Assets!);
}
catch (HttpRequestException)
{
@@ -509,6 +481,65 @@ manifestUri is null ||
}
}
+ internal static bool TryGetReleaseManifestAsset(
+ IReadOnlyCollection? releaseAssets,
+ string releaseTag,
+ out GitHubAsset? manifestAsset,
+ out Uri? manifestUri,
+ out string? expectedHash)
+ {
+ manifestAsset = null;
+ manifestUri = null;
+ expectedHash = null;
+ List matches = releaseAssets?
+ .Where(asset => string.Equals(asset.Name, ReleaseManifestVerifier.ManifestName, StringComparison.Ordinal))
+ .ToList() ?? [];
+ if (matches.Count != 1)
+ return false;
+
+ manifestAsset = matches[0];
+ string expectedUrl = $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{releaseTag}/{ReleaseManifestVerifier.ManifestName}";
+ expectedHash = TryParseSha256Digest(manifestAsset.Digest);
+ return manifestAsset.Size is > 0 and <= MaxReleaseManifestBytes &&
+ TryGetTrustedReleaseAssetUri(manifestAsset.BrowserDownloadUrl, out manifestUri) &&
+ manifestUri is not null &&
+ string.Equals(manifestUri.AbsoluteUri, expectedUrl, StringComparison.Ordinal) &&
+ expectedHash is not null;
+ }
+
+ internal static IReadOnlyCollection? ValidateDownloadedReleaseManifest(
+ byte[] bytes,
+ GitHubAsset manifestAsset,
+ string expectedHash,
+ string releaseTag,
+ bool prerelease,
+ IReadOnlyCollection releaseAssets)
+ {
+ 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;
+
+ List publishedAssets = releaseAssets
+ .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,
+ prerelease,
+ publishedAssets,
+ out IReadOnlyDictionary assets)
+ ? assets.Values.ToList()
+ : null;
+ }
+
internal static (string? ManifestUrl, string? ManifestSha256, long ManifestSize, Uri? ArchiveUrl, string? ArchiveName, string? ArchiveSha256, long ArchiveSize) GetPatchAssets(IReadOnlyCollection assets)
{
if (assets.Count == 0)
diff --git a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
index 62c5bad6..8e1c41f0 100644
--- a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
+++ b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using VaultSync.UI.Services;
using Xunit;
@@ -76,6 +78,73 @@ public void AssetsMissingFromGitHub_AreRejected()
CreateManifest(), Tag, prerelease: false, [], out _));
}
+ [Theory]
+ [InlineData("\"version\":\"1.8.7\"", "\"version\":null")]
+ [InlineData("\"version\":\"1.8.7\"", "\"version\":\"not-a-version\"")]
+ [InlineData("\"channel\":\"stable\"", "\"channel\":\"beta\"")]
+ [InlineData("\"tag\":\"v1.8.7\"", "\"tag\":\"v1.8.8\"")]
+ [InlineData("\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"", "\"commit\":null")]
+ [InlineData("\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"", "\"commit\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"")]
+ [InlineData("\"repository\":\"ATAC-Helicopter/VaultSync\"", "\"repository\":\"other/repository\"")]
+ [InlineData("\"compatiblePredecessors\":[\"1.8.6\"]", "\"compatiblePredecessors\":[]")]
+ [InlineData("\"compatiblePredecessors\":[\"1.8.6\"]", "\"compatiblePredecessors\":[\"1.8.7\"]")]
+ [InlineData("\"compatiblePredecessors\":[\"1.8.6\"]", "\"compatiblePredecessors\":[\"1.8.6\",\"1.8.6\"]")]
+ public void InvalidReleaseIdentity_IsRejected(string original, string replacement)
+ {
+ string manifest = CreateManifest().Replace(original, replacement, StringComparison.Ordinal);
+
+ Assert.False(ReleaseManifestVerifier.TryValidate(
+ manifest, Tag, prerelease: false, CreatePublishedAssets(), out _));
+ }
+
+ [Theory]
+ [InlineData("\"name\":\"VaultSync-1.8.7-linux-x64.tar.gz\"", "\"name\":\"../VaultSync.tar.gz\"")]
+ [InlineData("\"sizeBytes\":10", "\"sizeBytes\":0")]
+ [InlineData("\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"", "\"sha256\":\"ABCDEF\"")]
+ [InlineData("\"platform\":\"linux\"", "\"platform\":\"other\"")]
+ [InlineData("\"architecture\":\"x64\"", "\"architecture\":\"x86\"")]
+ [InlineData("\"packageKind\":\"archive\"", "\"packageKind\":\"unknown\"")]
+ public void InvalidManifestAssetContract_IsRejected(string original, string replacement)
+ {
+ string manifest = CreateManifest().Replace(original, replacement, StringComparison.Ordinal);
+
+ Assert.False(ReleaseManifestVerifier.TryValidate(
+ manifest, Tag, prerelease: false, CreatePublishedAssets(), out _));
+ }
+
+ [Fact]
+ public void DuplicateOrBlankPublishedAssetNames_AreRejected()
+ {
+ List duplicate = CreatePublishedAssets();
+ duplicate.Add(duplicate[0]);
+ List blank = CreatePublishedAssets();
+ blank.Add(new PublishedReleaseAsset("", Url, 10, $"sha256:{Hash}"));
+
+ Assert.False(ReleaseManifestVerifier.TryValidate(
+ CreateManifest(), Tag, prerelease: false, duplicate, out _));
+ Assert.False(ReleaseManifestVerifier.TryValidate(
+ CreateManifest(), Tag, prerelease: false, blank, out _));
+ }
+
+ [Fact]
+ public void MatchingBetaReleaseIdentity_IsAccepted()
+ {
+ const string betaTag = "v1.8.7-beta.1";
+ string manifest = CreateManifest()
+ .Replace("\"version\":\"1.8.7\"", "\"version\":\"1.8.7-beta.1\"", StringComparison.Ordinal)
+ .Replace("\"channel\":\"stable\"", "\"channel\":\"beta\"", StringComparison.Ordinal)
+ .Replace(Tag, betaTag, StringComparison.Ordinal);
+ List published = CreatePublishedAssets()
+ .Select(asset => asset with
+ {
+ DownloadUrl = asset.DownloadUrl?.Replace(Tag, betaTag, StringComparison.Ordinal)
+ })
+ .ToList();
+
+ Assert.True(ReleaseManifestVerifier.TryValidate(
+ manifest, betaTag, prerelease: true, published, out _));
+ }
+
[Fact]
public void PlatformAssets_AreSelectedOnlyFromVerifiedManifestEntries()
{
@@ -127,6 +196,75 @@ public void PlatformAssets_RejectUntrustedUrlsAndEmptyCollections()
Assert.Null(GitHubUpdateService.GetInstallerAsset(untrusted).InstallerUrl);
}
+ [Fact]
+ public void ReleaseManifestDownloadContract_ResolvesAndValidatesExactPublishedBytes()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(CreateManifest());
+ string hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ List releaseAssets = CreateGitHubAssets(bytes.Length, hash);
+
+ bool resolved = GitHubUpdateService.TryGetReleaseManifestAsset(
+ releaseAssets,
+ Tag,
+ out GitHubUpdateService.GitHubAsset manifestAsset,
+ out Uri manifestUri,
+ out string expectedHash);
+ IReadOnlyCollection verified = GitHubUpdateService.ValidateDownloadedReleaseManifest(
+ bytes,
+ Assert.IsType(manifestAsset),
+ Assert.IsType(expectedHash),
+ Tag,
+ prerelease: false,
+ releaseAssets);
+
+ Assert.True(resolved);
+ Assert.Equal($"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{Tag}/{ReleaseManifestVerifier.ManifestName}", manifestUri?.AbsoluteUri);
+ Assert.Single(verified!);
+ }
+
+ [Fact]
+ public void ReleaseManifestDownloadContract_RejectsAmbiguousOrUntrustedDescriptors()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(CreateManifest());
+ string hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ List valid = CreateGitHubAssets(bytes.Length, hash);
+ GitHubUpdateService.GitHubAsset manifest = valid.Single(asset => asset.Name == ReleaseManifestVerifier.ManifestName);
+
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(null, Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [.. valid, CopyManifest(manifest)], Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [CopyManifest(manifest, size: 0)], Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [CopyManifest(manifest, size: 1_048_577)], Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [CopyManifest(manifest, url: "https://evil.example/manifest.json")], Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [CopyManifest(manifest, digest: "sha256:invalid")], Tag, out _, out _, out _));
+ Assert.False(GitHubUpdateService.TryGetReleaseManifestAsset(
+ [manifest], "v1.8.8", out _, out _, out _));
+ }
+
+ [Fact]
+ public void ReleaseManifestDownloadContract_RejectsChangedOrInvalidPayloads()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(CreateManifest());
+ string hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ List assets = CreateGitHubAssets(bytes.Length, hash);
+ GitHubUpdateService.GitHubAsset manifest = assets.Single(asset => asset.Name == ReleaseManifestVerifier.ManifestName);
+
+ Assert.Null(GitHubUpdateService.ValidateDownloadedReleaseManifest(
+ bytes, CopyManifest(manifest, size: bytes.Length + 1), hash, Tag, false, assets));
+ Assert.Null(GitHubUpdateService.ValidateDownloadedReleaseManifest(
+ bytes, manifest, new string('0', 64), Tag, false, assets));
+
+ byte[] invalidJson = Encoding.UTF8.GetBytes("{}");
+ string invalidHash = Convert.ToHexString(SHA256.HashData(invalidJson)).ToLowerInvariant();
+ GitHubUpdateService.GitHubAsset invalidManifest = CopyManifest(manifest, size: invalidJson.Length, digest: $"sha256:{invalidHash}");
+ Assert.Null(GitHubUpdateService.ValidateDownloadedReleaseManifest(
+ invalidJson, invalidManifest, invalidHash, Tag, false, [invalidManifest]));
+ }
+
private static string CreateManifest()
{
var manifest = new
@@ -206,4 +344,34 @@ private static List CreatePlatformAssets()
Sha256 = Hash,
DownloadUrl = $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{Tag}/{name}"
};
+
+ private static List CreateGitHubAssets(long manifestSize, string manifestHash) =>
+ [
+ new()
+ {
+ Name = AssetName,
+ BrowserDownloadUrl = Url,
+ Size = 10,
+ Digest = $"sha256:{Hash}"
+ },
+ new()
+ {
+ Name = ReleaseManifestVerifier.ManifestName,
+ BrowserDownloadUrl = $"https://github.com/ATAC-Helicopter/VaultSync/releases/download/{Tag}/{ReleaseManifestVerifier.ManifestName}",
+ Size = manifestSize,
+ Digest = $"sha256:{manifestHash}"
+ }
+ ];
+
+ private static GitHubUpdateService.GitHubAsset CopyManifest(
+ GitHubUpdateService.GitHubAsset source,
+ long? size = null,
+ string url = null,
+ string digest = null) => new()
+ {
+ Name = source.Name,
+ BrowserDownloadUrl = url ?? source.BrowserDownloadUrl,
+ Size = size ?? source.Size,
+ Digest = digest ?? source.Digest
+ };
}
From a2690be94ef075a5fafacacd9960ad705b49eddd Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:58:45 +0200
Subject: [PATCH 36/54] Limit imported snapshot tombstones to deletions
---
CHANGELOG.md | 1 +
ROADMAP.md | 6 ++
docs/RELEASE_1.8.7.md | 3 +
.../Services/MetadataSyncService.cs | 9 ++-
.../VaultSync.Core.Tests/MetadataSyncTests.cs | 79 +++++++++++++++++++
5 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa881283..13041bd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@
- [BUG-18104] Corrected the Python pytest-cache rule and removed unsupported VS Code negation rules that previously excluded intended shared configuration.
- [BUG-18105] Prevented metadata-import previews from double-counting projects and backups that are represented by both portable metadata and legacy repository folders.
- [BUG-18106] Normalized macOS SMB mount diagnostics to remove the complete credential-bearing share identity before masking any remaining raw or escaped password text.
+- [BUG-18107] Stopped metadata import from exporting deletion tombstones for snapshots that were preserved because they still have local backups or never existed locally.
## [1.8.6] - 10.08.2026
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index c60a496a..5131c0d8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -524,6 +524,12 @@ that exists only on the release branch or remains planned.
replace known credential-bearing share URLs with their credential-free
display identity, and preserve unrelated diagnostic text.
- Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
+- [x] `BUG-18107` `P0` Export snapshot tombstones only for snapshots actually
+ deleted during metadata import.
+ - Acceptance: snapshots retained by local backups and unknown remote snapshot
+ IDs never produce deletion tombstones; each locally deleted unreferenced
+ snapshot produces exactly one tombstone.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
### Delivery sequence
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 7bb3469c..9c8a8aec 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -67,6 +67,9 @@ assumed and must be approved explicitly if the release needs one.
- macOS SMB mount errors now normalize the complete credential-bearing share
identity before masking any remaining raw or escaped password text, producing
clean credential-free diagnostics (`BUG-18106`).
+- Metadata import now exports snapshot tombstones only for snapshots it actually
+ deletes; snapshots retained by local backups and unknown remote-only IDs are
+ no longer advertised as deleted (`BUG-18107`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/src/VaultSync.Core/Services/MetadataSyncService.cs b/src/VaultSync.Core/Services/MetadataSyncService.cs
index a3cbb369..8dc9dd54 100644
--- a/src/VaultSync.Core/Services/MetadataSyncService.cs
+++ b/src/VaultSync.Core/Services/MetadataSyncService.cs
@@ -649,7 +649,7 @@ private MetadataSyncResult ImportFromStoreInternal(
if (missingSnapshotExternalIds.Count > 0)
{
- int removedSnapshots = 0;
+ var removedSnapshotExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (string? missingExternalId in missingSnapshotExternalIds)
{
Snapshot? snapshot = _repo.GetSnapshotByExternalId(missingExternalId);
@@ -664,12 +664,13 @@ private MetadataSyncResult ImportFromStoreInternal(
continue;
(int snapshots, _) = _repo.DeleteSnapshotsById(project.Name, [snapshot.Id]);
- removedSnapshots += snapshots;
+ if (snapshots > 0)
+ removedSnapshotExternalIds.Add(missingExternalId);
}
- if (removedSnapshots > 0 && opts.ExportMissingTombstonesOnImport)
+ if (removedSnapshotExternalIds.Count > 0 && opts.ExportMissingTombstonesOnImport)
{
- TryExportMissingSnapshotTombstones(rootPath, missingSnapshotExternalIds);
+ TryExportMissingSnapshotTombstones(rootPath, removedSnapshotExternalIds);
}
}
diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
index c638fdf4..547b5e59 100644
--- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
+++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
@@ -368,6 +368,85 @@ void AddMetaBackup(string externalId, string projectExternalId, string snapshotE
}
}
+ [Fact]
+ public void ImportFromStore_RemovesUnreferencedSnapshotsAndPreservesSnapshotsWithLocalBackups()
+ {
+ string metaRoot = CreateTempDir();
+ string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db");
+ SqliteRepository repo = CreateRepository(dbPath);
+ int projectId = repo.AddProject(new Project
+ {
+ ExternalId = "snapshot-cleanup-project",
+ Name = "Snapshot Cleanup",
+ RootPath = CreateTempDir(),
+ Preset = "dotnet",
+ CreatedUtc = DateTime.UtcNow.AddDays(-5)
+ });
+ repo.CreateSnapshotFromMetadata(
+ "remote-unreferenced",
+ projectId,
+ DateTime.UtcNow.AddDays(-3),
+ fileCount: 1,
+ totalBytes: 32);
+ int protectedSnapshotId = repo.CreateSnapshotFromMetadata(
+ "remote-locally-backed",
+ projectId,
+ DateTime.UtcNow.AddDays(-2),
+ fileCount: 2,
+ totalBytes: 64);
+ repo.CreateBackupFromMetadata(
+ "local-backup",
+ projectId,
+ protectedSnapshotId,
+ DateTime.UtcNow.AddDays(-1),
+ "manual",
+ 64,
+ "local/backup",
+ metaRoot,
+ "Primary",
+ isProtected: false,
+ isImported: false);
+
+ MetadataStore store = CreateStore(metaRoot);
+ SeedMetaInfo(store, "remote-machine");
+ store.UpsertProject(new MetaProject
+ {
+ ExternalId = "snapshot-cleanup-project",
+ Name = "Snapshot Cleanup",
+ Preset = "dotnet",
+ RootPathHint = CreateTempDir(),
+ CreatedUtc = DateTime.UtcNow.AddDays(-5),
+ SettingsJson = "{}",
+ UpdatedUtc = DateTime.UtcNow
+ });
+ AddSnapshot("remote-unreferenced");
+ AddSnapshot("remote-locally-backed");
+ AddSnapshot("remote-not-local");
+
+ MetadataSyncResult result = new MetadataSyncService(repo)
+ .ImportFromStore(metaRoot, MetadataSyncOptions.Default);
+
+ Assert.Equal(MetadataSyncStatus.Success, result.Status);
+ Assert.Null(repo.GetSnapshotByExternalId("remote-unreferenced"));
+ Assert.NotNull(repo.GetSnapshotByExternalId("remote-locally-backed"));
+ Assert.NotNull(repo.GetBackupByExternalId("local-backup"));
+ MetaTombstone exportedTombstone = Assert.Single(
+ store.ListTombstones(),
+ tombstone => tombstone.EntityType == "snapshot");
+ Assert.Equal("remote-unreferenced", exportedTombstone.EntityId);
+ void AddSnapshot(string externalId)
+ {
+ store.UpsertSnapshot(new MetaSnapshot
+ {
+ ExternalId = externalId,
+ ProjectExternalId = "snapshot-cleanup-project",
+ CreatedUtc = DateTime.UtcNow.AddDays(-2),
+ FileCount = 1,
+ TotalBytes = 32
+ });
+ }
+ }
+
[Fact]
public async System.Threading.Tasks.Task ExportBackupTombstoneToStoreAsync_PersistsTombstoneAndReleasesItsPerRootGate()
{
From 9f90a1ccd89f51c1e2e40028e7d1e432618d4289 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 17:01:15 +0200
Subject: [PATCH 37/54] Standardize destination status presentation
---
.../Commands/DestinationCommand.cs | 28 +++++++++------
.../DestinationCommandTests.cs | 34 +++++++++++++++++++
2 files changed, 52 insertions(+), 10 deletions(-)
create mode 100644 tests/VaultSync.Core.Tests/DestinationCommandTests.cs
diff --git a/src/VaultSync.CLI/Commands/DestinationCommand.cs b/src/VaultSync.CLI/Commands/DestinationCommand.cs
index 148e7a61..0f361cbe 100644
--- a/src/VaultSync.CLI/Commands/DestinationCommand.cs
+++ b/src/VaultSync.CLI/Commands/DestinationCommand.cs
@@ -79,10 +79,7 @@ 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)
- ? defaultMessage
- : resolution.Message;
+ string message = ResolveDestinationMessage(reachable, resolution.Message);
NetworkMountService.Cleanup(resolution);
return new DestinationInfo(alias, path, status, reachable, message);
}
@@ -115,18 +112,29 @@ private static void WriteTable(IEnumerable results, bool test)
foreach (DestinationInfo row in results)
{
- string testedDetail = row.Reachable ? "Reachable" : row.Message;
- string detail = test
- ? testedDetail
- : row.Message;
-
- table.AddRow(row.Alias, row.Path, row.Status, detail);
+ table.AddRow(row.Alias, row.Path, row.Status, ResolveTableDetail(row, test));
}
AnsiConsole.MarkupLine("[bold]Configured backup destinations[/]");
AnsiConsole.Write(table);
}
+ internal static string ResolveDestinationMessage(bool reachable, string? message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ return message;
+
+ return reachable ? "Reachable" : "Unreachable";
+ }
+
+ internal static string ResolveTableDetail(DestinationInfo row, bool test)
+ {
+ if (!test)
+ return row.Message;
+
+ return row.Reachable ? "Reachable" : row.Message;
+ }
+
private static List BuildActiveDestinations(AppConfig config)
{
var list = new List();
diff --git a/tests/VaultSync.Core.Tests/DestinationCommandTests.cs b/tests/VaultSync.Core.Tests/DestinationCommandTests.cs
new file mode 100644
index 00000000..9885475a
--- /dev/null
+++ b/tests/VaultSync.Core.Tests/DestinationCommandTests.cs
@@ -0,0 +1,34 @@
+using VaultSync.CLI.Commands;
+using Xunit;
+
+namespace VaultSync.Core.Tests;
+
+public sealed class DestinationCommandTests
+{
+ [Theory]
+ [InlineData(true, null, "Reachable")]
+ [InlineData(false, "", "Unreachable")]
+ [InlineData(true, "Mounted primary", "Mounted primary")]
+ [InlineData(false, "Mount failed", "Mount failed")]
+ public void ResolveDestinationMessage_PreservesDetailsOrProvidesFallback(
+ bool reachable,
+ string message,
+ string expected)
+ {
+ Assert.Equal(expected, DestinationCommand.ResolveDestinationMessage(reachable, message));
+ }
+
+ [Theory]
+ [InlineData(false, true, "Configured")]
+ [InlineData(true, true, "Reachable")]
+ [InlineData(true, false, "Mount failed")]
+ public void ResolveTableDetail_UsesReachabilityOnlyForTestedRows(
+ bool test,
+ bool reachable,
+ string expected)
+ {
+ var row = new DestinationInfo("Primary", "/backup", "Active", reachable, reachable ? "Configured" : "Mount failed");
+
+ Assert.Equal(expected, DestinationCommand.ResolveTableDetail(row, test));
+ }
+}
From 5b27351b17aceb577ea0dae63965922adb03ae48 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 17:12:36 +0200
Subject: [PATCH 38/54] Cover coordination and metadata failure boundaries
---
.../InstallationIdentityServiceTests.cs | 13 ++
.../VaultSync.Core.Tests/MetadataSyncTests.cs | 54 +++++++
.../RepositoryLeaseServiceTests.cs | 138 ++++++++++++++++++
3 files changed, 205 insertions(+)
diff --git a/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs b/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs
index c97797b4..bc846966 100644
--- a/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs
+++ b/tests/VaultSync.Core.Tests/InstallationIdentityServiceTests.cs
@@ -10,6 +10,19 @@ namespace VaultSync.Core.Tests;
public sealed class InstallationIdentityServiceTests
{
+ [Fact]
+ public void Constructor_WithoutOverrideUsesVaultSyncApplicationDataDirectory()
+ {
+ var service = new InstallationIdentityService();
+
+ Assert.Equal(
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "VaultSync",
+ InstallationIdentityService.IdentityFileName),
+ service.IdentityPath);
+ }
+
[Fact]
public void GetOrCreate_CreatesCanonicalDurableIdentity()
{
diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
index 547b5e59..d63f2e65 100644
--- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
+++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
@@ -45,6 +45,60 @@ public async System.Threading.Tasks.Task PreviewImportFromStoreAsync_WithInvalid
Assert.Equal(MetadataSyncStatus.NoStore, preview.Status);
}
+ [Fact]
+ public void PreviewImportFromStore_RejectsNewerMetadataSchemaWithoutMutatingRepository()
+ {
+ string metaRoot = CreateTempDir();
+ string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db");
+ MetadataStore store = CreateStore(metaRoot);
+ store.UpsertMetaInfo(new MetaInfo
+ {
+ SchemaVersion = MetadataStore.CurrentSchemaVersion + 1,
+ CreatedUtc = DateTime.UtcNow,
+ LastWriteUtc = DateTime.UtcNow,
+ WriterAppVersion = "future-version",
+ WriterMachineId = "future-machine"
+ });
+ SqliteRepository repo = CreateRepository(dbPath);
+
+ MetadataSyncPreview preview = new MetadataSyncService(repo).PreviewImportFromStore(metaRoot);
+
+ Assert.Equal(MetadataSyncStatus.Incompatible, preview.Status);
+ Assert.Empty(repo.GetAllProjects());
+ }
+
+ [Fact]
+ public void PreviewImportFromStore_CountsLegacyFoldersOnlyWhenProjectsMayBeCreated()
+ {
+ string metaRoot = CreateTempDir();
+ string firstBackup = Path.Combine(metaRoot, "Legacy Project", "2026-08-14_09-30-00");
+ string secondBackup = Path.Combine(metaRoot, "Legacy Project", "2026-08-15_10-45-00");
+ Directory.CreateDirectory(firstBackup);
+ Directory.CreateDirectory(secondBackup);
+ File.WriteAllText(Path.Combine(firstBackup, "first.txt"), "first");
+ File.WriteAllText(Path.Combine(secondBackup, "second.txt"), "second");
+ string dbPath = Path.Combine(CreateTempDir(), "vaultsync.db");
+ SqliteRepository repo = CreateRepository(dbPath);
+ var service = new MetadataSyncService(repo);
+
+ MetadataSyncPreview allowed = service.PreviewImportFromStore(
+ metaRoot,
+ new MetadataSyncOptions(AllowCreateProjects: true, MarkNeedsRestoreOnImport: true));
+ MetadataSyncPreview blocked = service.PreviewImportFromStore(
+ metaRoot,
+ new MetadataSyncOptions(AllowCreateProjects: false, MarkNeedsRestoreOnImport: true));
+
+ Assert.Equal(MetadataSyncStatus.Success, allowed.Status);
+ Assert.Equal(1, allowed.NewProjects);
+ Assert.Equal(2, allowed.NewSnapshots);
+ Assert.Equal(2, allowed.NewBackups);
+ Assert.Equal(MetadataSyncStatus.NoStore, blocked.Status);
+ Assert.Equal(0, blocked.NewProjects);
+ Assert.Equal(0, blocked.NewSnapshots);
+ Assert.Equal(0, blocked.NewBackups);
+ Assert.Empty(repo.GetAllProjects());
+ }
+
[Fact]
public void PreviewImportFromStore_CountsChangesWithoutMutatingTheRepository()
{
diff --git a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs
index cfb2fd81..57de6d2d 100644
--- a/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs
+++ b/tests/VaultSync.Core.Tests/RepositoryLeaseServiceTests.cs
@@ -110,6 +110,9 @@ public void Renew_ExtendsOwnedLeaseAndDisposeReleasesIt()
Assert.Equal(RepositoryLeaseState.Available, service.Inspect(root.Path).State);
Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path));
+ Assert.False(handle.Renew());
+ Assert.False(handle.IsOwner);
+ handle.Dispose();
}
[Fact]
@@ -140,6 +143,14 @@ public void StaleLeaseRequiresExplicitNonceBoundTakeoverAndPreservesEvidence()
RepositoryLeaseEvidence evidence = Assert.Single(RepositoryLeaseService.ListEvidence(root.Path));
Assert.Equal("stale-takeover", evidence.Disposition);
Assert.Equal(ordinaryAcquire.Inspection.Lease?.Nonce, evidence.Nonce);
+ Assert.Equal(oldHandle.Lease.InstallationId, evidence.InstallationId);
+ Assert.Equal(oldHandle.Lease.HostLabel, evidence.HostLabel);
+ Assert.Equal(oldHandle.Lease.Operation, evidence.Operation);
+ Assert.Equal(oldHandle.Lease.AppVersion, evidence.AppVersion);
+ Assert.Equal(oldHandle.Lease.AcquiredUtc, evidence.AcquiredUtc);
+ Assert.Equal(oldHandle.Lease.HeartbeatUtc, evidence.HeartbeatUtc);
+ Assert.Equal(oldHandle.Lease.ExpiresUtc, evidence.ExpiresUtc);
+ Assert.Equal(clock.GetUtcNow(), evidence.RecordedUtc);
}
[Fact]
@@ -220,9 +231,136 @@ public void Inspect_ExistingDatabaseWithoutLeaseSchemaIsInvalid()
public void ListEvidence_MissingDatabaseReturnsEmpty()
{
using var root = new TempDirectory();
+
+ Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path));
+ }
+
+ [Fact]
+ public void ListEvidence_DatabaseWithoutEvidenceSchemaReturnsEmpty()
+ {
+ using var root = new TempDirectory();
+ string databasePath = RepositoryLeaseService.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);");
+ }
+
+ Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path));
+ }
+
+ [Fact]
+ public void CorruptCoordinationDatabaseFailsClosedAcrossReadAndMutationOperations()
+ {
+ using var root = new TempDirectory();
+ string databasePath = RepositoryLeaseService.GetDatabasePath(root.Path);
+ Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
+ File.WriteAllText(databasePath, "not a SQLite database");
+ var service = new RepositoryLeaseService();
+ string installationId = Guid.NewGuid().ToString("N");
+ string nonce = Guid.NewGuid().ToString("N");
+
+ Assert.Equal(RepositoryLeaseState.Unavailable, service.Inspect(root.Path).State);
+ Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path));
+ Assert.False(RepositoryLeaseService.TryRelease(root.Path, installationId, nonce));
+ Assert.Null(service.TryRenew(root.Path, installationId, nonce, TimeSpan.FromMinutes(5)));
+ }
+
+ [Fact]
+ public void MissingLeaseCannotBeTakenOverRenewedOrReleased()
+ {
+ using var root = new TempDirectory();
+ var service = new RepositoryLeaseService();
+ string installationId = Guid.NewGuid().ToString("N");
+ string nonce = Guid.NewGuid().ToString("N");
+
+ RepositoryLeaseAcquireResult takeover = service.TakeOverStale(
+ root.Path,
+ nonce,
+ CreateRequest("stale-takeover", installationId));
+
+ Assert.Equal(RepositoryLeaseAcquireStatus.Invalid, takeover.Status);
+ Assert.False(RepositoryLeaseService.TryRelease(root.Path, installationId, nonce));
+ Assert.Null(service.TryRenew(root.Path, installationId, nonce, TimeSpan.FromMinutes(5)));
+ }
+
+ [Fact]
+ public void ForeignOwnerCannotRenewOrReleaseLease()
+ {
+ using var root = new TempDirectory();
+ var service = new RepositoryLeaseService();
+ using RepositoryLeaseHandle owner = AssertAcquired(
+ service.TryAcquire(root.Path, CreateRequest("metadata-export")));
+
+ Assert.Null(service.TryRenew(
+ root.Path,
+ Guid.NewGuid().ToString("N"),
+ owner.Lease.Nonce,
+ TimeSpan.FromMinutes(5)));
+ Assert.False(RepositoryLeaseService.TryRelease(
+ root.Path,
+ owner.Lease.InstallationId,
+ Guid.NewGuid().ToString("N")));
+ Assert.True(owner.IsOwner);
+ }
+
+ [Fact]
+ public void ExpiredOwnerCannotRenewLease()
+ {
+ using var root = new TempDirectory();
+ var clock = new ManualTimeProvider(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero));
+ var service = new RepositoryLeaseService(clock, TimeSpan.Zero);
+ using RepositoryLeaseHandle owner = AssertAcquired(
+ service.TryAcquire(root.Path, CreateRequest("metadata-export")));
+
+ clock.Advance(TimeSpan.FromMinutes(6));
+
+ Assert.False(owner.Renew());
+ Assert.Equal(RepositoryLeaseState.Stale, service.Inspect(root.Path).State);
+ }
+
+ [Fact]
+ public void InvalidRepositoryRootFailsClosedWithoutThrowing()
+ {
var service = new RepositoryLeaseService();
+ const string invalidRoot = "invalid\0root";
+ string nonce = Guid.NewGuid().ToString("N");
+
+ Assert.Equal(RepositoryLeaseState.Invalid, service.Inspect(invalidRoot).State);
+ Assert.Equal(
+ RepositoryLeaseAcquireStatus.Invalid,
+ service.TakeOverStale(invalidRoot, nonce, CreateRequest("stale-takeover")).Status);
+ Assert.Empty(RepositoryLeaseService.ListEvidence(invalidRoot));
+ }
+ [Fact]
+ public void LinkedCoordinationDatabaseFailsClosed()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ using var root = new TempDirectory();
+ using var target = new TempDirectory();
+ string databasePath = RepositoryLeaseService.GetDatabasePath(root.Path);
+ string targetPath = Path.Combine(target.Path, "coordination.db");
+ Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
+ File.WriteAllText(targetPath, "external data");
+ File.CreateSymbolicLink(databasePath, targetPath);
+ var service = new RepositoryLeaseService();
+ string installationId = Guid.NewGuid().ToString("N");
+ string nonce = Guid.NewGuid().ToString("N");
+
+ Assert.Equal(RepositoryLeaseState.Invalid, service.Inspect(root.Path).State);
+ Assert.Equal(
+ RepositoryLeaseAcquireStatus.Invalid,
+ service.TryAcquire(root.Path, CreateRequest("metadata-export")).Status);
+ Assert.Equal(
+ RepositoryLeaseAcquireStatus.Invalid,
+ service.TakeOverStale(root.Path, nonce, CreateRequest("stale-takeover")).Status);
Assert.Empty(RepositoryLeaseService.ListEvidence(root.Path));
+ Assert.False(RepositoryLeaseService.TryRelease(root.Path, installationId, nonce));
+ Assert.Null(service.TryRenew(root.Path, installationId, nonce, TimeSpan.FromMinutes(5)));
}
[Fact]
From b1ce7b6585787ae5a08ddf135f95c38afea6acf0 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 17:43:19 +0200
Subject: [PATCH 39/54] Cache verified updater manifests across restarts
---
CHANGELOG.md | 1 +
ROADMAP.md | 5 +
docs/RELEASE_1.8.7.md | 3 +
docs/UPDATER.md | 6 +
.../Services/GitHubUpdateService.cs | 30 ++-
.../Services/PatchUpdateService.cs | 33 ++-
.../Services/VerifiedReleaseAssetCache.cs | 216 ++++++++++++++++++
.../VerifiedReleaseAssetCacheTests.cs | 104 +++++++++
8 files changed, 385 insertions(+), 13 deletions(-)
create mode 100644 src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
create mode 100644 tests/VaultSync.Core.Tests/VerifiedReleaseAssetCacheTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13041bd5..d2ddd8e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@
- [BUG-18105] Prevented metadata-import previews from double-counting projects and backups that are represented by both portable metadata and legacy repository folders.
- [BUG-18106] Normalized macOS SMB mount diagnostics to remove the complete credential-bearing share identity before masking any remaining raw or escaped password text.
- [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.
## [1.8.6] - 10.08.2026
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 5131c0d8..538664a7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -530,6 +530,11 @@ that exists only on the release branch or remains planned.
IDs never produce deletion tombstones; each locally deleted unreferenced
snapshot produces exactly one tombstone.
- Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
+- [x] `BUG-18108` `P1` Stop repeated immutable updater manifest downloads.
+ - Acceptance: canonical and platform patch manifests are reused across
+ application restarts only when their release URL, exact size, and trusted
+ SHA-256 identity still match; tampered and linked entries fail closed.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
### Delivery sequence
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 9c8a8aec..6037b03f 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -70,6 +70,9 @@ assumed and must be approved explicitly if the release needs one.
- Metadata import now exports snapshot tombstones only for snapshots it actually
deletes; snapshots retained by local backups and unknown remote-only IDs are
no longer advertised as deleted (`BUG-18107`).
+- Canonical and platform patch manifests now use an immutable, digest-verified
+ on-disk cache, preventing routine update checks and application restarts from
+ inflating GitHub JSON asset download counts (`BUG-18108`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/docs/UPDATER.md b/docs/UPDATER.md
index 82c3989f..791d11e3 100644
--- a/docs/UPDATER.md
+++ b/docs/UPDATER.md
@@ -45,6 +45,12 @@ Linux can use architecture-specific patch names:
SHA-256. A missing manifest, unsupported schema, duplicate or unexpected
asset, unsafe URL, or metadata mismatch fails closed instead of presenting an
unverified download.
+- Canonical and platform patch manifests are immutable for a published release
+ and are cached on disk by official URL, exact size, and GitHub-published
+ SHA-256. Cache bytes are rehashed before every use; linked, truncated,
+ oversized, or tampered entries are ignored and never trusted as release
+ metadata. This keeps scheduled checks and restarts from repeatedly increasing
+ GitHub asset download counters for the same release.
- 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.
diff --git a/src/VaultSync.UI/Services/GitHubUpdateService.cs b/src/VaultSync.UI/Services/GitHubUpdateService.cs
index d1722372..957394aa 100644
--- a/src/VaultSync.UI/Services/GitHubUpdateService.cs
+++ b/src/VaultSync.UI/Services/GitHubUpdateService.cs
@@ -244,7 +244,8 @@ private static HttpClient CreateHttpClient()
var client = new HttpClient
{
BaseAddress = new Uri("https://api.github.com/"),
- Timeout = TimeSpan.FromSeconds(20)
+ Timeout = TimeSpan.FromSeconds(20),
+ MaxResponseContentBufferSize = MaxReleaseManifestBytes
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("VaultSync-Updater/1.0");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
@@ -462,14 +463,35 @@ internal sealed class GitHubAsset
try
{
- byte[] bytes = await s_httpClient.GetByteArrayAsync(manifestUri!, cancellationToken).ConfigureAwait(false);
- return ValidateDownloadedReleaseManifest(
+ bool cacheHit = VerifiedReleaseAssetCache.Default.TryRead(
+ manifestUri!.AbsoluteUri,
+ expectedHash!,
+ manifestAsset!.Size,
+ MaxReleaseManifestBytes,
+ out byte[] bytes);
+ if (!cacheHit)
+ {
+ bytes = await s_httpClient.GetByteArrayAsync(manifestUri, cancellationToken).ConfigureAwait(false);
+ }
+
+ IReadOnlyCollection? verified = ValidateDownloadedReleaseManifest(
bytes,
- manifestAsset!,
+ manifestAsset,
expectedHash!,
releaseTag,
release.Prerelease,
release.Assets!);
+ if (!cacheHit && verified is not null)
+ {
+ VerifiedReleaseAssetCache.Default.Write(
+ manifestUri.AbsoluteUri,
+ expectedHash!,
+ manifestAsset.Size,
+ MaxReleaseManifestBytes,
+ bytes);
+ }
+
+ return verified;
}
catch (HttpRequestException)
{
diff --git a/src/VaultSync.UI/Services/PatchUpdateService.cs b/src/VaultSync.UI/Services/PatchUpdateService.cs
index 0e642f19..09d0eb63 100644
--- a/src/VaultSync.UI/Services/PatchUpdateService.cs
+++ b/src/VaultSync.UI/Services/PatchUpdateService.cs
@@ -105,8 +105,7 @@ public sealed class PatchUpdateService
internal const int MaxPatchFileCount = 100_000;
internal const long MaxPatchManifestBytes = 4L * 1024 * 1024;
private static readonly HttpClient s_httpClient = CreateHttpClient();
- private static readonly TimeSpan s_manifestCacheWindow = TimeSpan.FromMinutes(30);
- private static readonly ConcurrentDictionary s_manifestCache =
+ private static readonly ConcurrentDictionary s_manifestCache =
new(StringComparer.OrdinalIgnoreCase);
public async Task PreparePatchAsync(
@@ -726,17 +725,27 @@ private static HttpClient CreateHttpClient()
CancellationToken cancellationToken)
{
string cacheKey = $"{manifestUrl}|{expectedSha256}|{expectedSize}";
- if (s_manifestCache.TryGetValue(cacheKey, out (PatchManifest Manifest, DateTimeOffset FetchedAt) cached))
+ if (expectedSize <= 0 || expectedSize > MaxPatchManifestBytes || !IsSha256(expectedSha256))
+ return null;
+
+ if (s_manifestCache.TryGetValue(cacheKey, out PatchManifest? cached))
+ return cached;
+
+ if (VerifiedReleaseAssetCache.Default.TryRead(
+ manifestUrl,
+ expectedSha256,
+ expectedSize,
+ MaxPatchManifestBytes,
+ out byte[] cachedPayload))
{
- if (DateTimeOffset.UtcNow - cached.FetchedAt < s_manifestCacheWindow)
+ PatchManifest? cachedManifest = JsonSerializer.Deserialize(cachedPayload);
+ if (cachedManifest is not null)
{
- return cached.Manifest;
+ s_manifestCache[cacheKey] = cachedManifest;
+ return cachedManifest;
}
}
- if (expectedSize <= 0 || expectedSize > MaxPatchManifestBytes || !IsSha256(expectedSha256))
- return null;
-
using HttpResponseMessage response = await s_httpClient.GetAsync(
manifestUrl,
HttpCompletionOption.ResponseHeadersRead,
@@ -765,7 +774,13 @@ response.Content.Headers.ContentLength is > MaxPatchManifestBytes ||
if (manifest is null)
return null;
- s_manifestCache[cacheKey] = (manifest, DateTimeOffset.UtcNow);
+ VerifiedReleaseAssetCache.Default.Write(
+ manifestUrl,
+ expectedSha256,
+ expectedSize,
+ MaxPatchManifestBytes,
+ payload);
+ s_manifestCache[cacheKey] = manifest;
return manifest;
}
diff --git a/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs b/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
new file mode 100644
index 00000000..270c3c9f
--- /dev/null
+++ b/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using VaultSync.Core.Services;
+
+namespace VaultSync.UI.Services
+{
+ ///
+ /// Persists immutable release metadata by trusted URL, size, and SHA-256 identity.
+ /// Cached bytes are revalidated before every use so disk contents never become a
+ /// substitute for the digest published by GitHub.
+ ///
+ internal sealed class VerifiedReleaseAssetCache
+ {
+ private static readonly ConcurrentDictionary s_pathGates =
+ new(StringComparer.Ordinal);
+
+ internal static VerifiedReleaseAssetCache Default { get; } =
+ new(ResolveDefaultCacheDirectory());
+
+ private readonly string _cacheDirectory;
+
+ internal VerifiedReleaseAssetCache(string cacheDirectory)
+ {
+ _cacheDirectory = string.IsNullOrWhiteSpace(cacheDirectory)
+ ? string.Empty
+ : Path.GetFullPath(cacheDirectory);
+ }
+
+ internal bool TryRead(
+ string assetUrl,
+ string expectedSha256,
+ long expectedSize,
+ long maximumSize,
+ out byte[] payload)
+ {
+ payload = [];
+ if (!TryGetCachePath(assetUrl, expectedSha256, expectedSize, maximumSize, out string cachePath))
+ return false;
+
+ try
+ {
+ if (!File.Exists(cachePath) || IsLinkedFile(cachePath))
+ return false;
+
+ using var stream = new FileStream(
+ cachePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: 4096,
+ FileOptions.SequentialScan);
+ if (stream.Length != expectedSize)
+ return false;
+
+ var candidate = new byte[(int)expectedSize];
+ stream.ReadExactly(candidate);
+ if (stream.ReadByte() != -1)
+ return false;
+
+ if (!IsExpectedPayload(candidate, expectedSha256, expectedSize, maximumSize))
+ return false;
+
+ payload = candidate;
+ return true;
+ }
+ catch (Exception ex) when (IsCacheException(ex))
+ {
+ return false;
+ }
+ }
+
+ internal void Write(
+ string assetUrl,
+ string expectedSha256,
+ long expectedSize,
+ long maximumSize,
+ byte[] payload)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+ if (!IsExpectedPayload(payload, expectedSha256, expectedSize, maximumSize) ||
+ !TryGetCachePath(assetUrl, expectedSha256, expectedSize, maximumSize, out string cachePath))
+ {
+ return;
+ }
+
+ object pathGate = s_pathGates.GetOrAdd(cachePath, static _ => new object());
+ lock (pathGate)
+ {
+ string temporaryPath = Path.Combine(
+ _cacheDirectory,
+ $".{Path.GetFileName(cachePath)}.{Guid.NewGuid():N}.tmp");
+ try
+ {
+ PrivateDataPermissions.EnsureDirectory(_cacheDirectory);
+ if (TryRead(assetUrl, expectedSha256, expectedSize, maximumSize, out _))
+ return;
+ if (File.Exists(cachePath) && IsLinkedFile(cachePath))
+ return;
+
+ using (var stream = new FileStream(
+ temporaryPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.WriteThrough))
+ {
+ stream.Write(payload);
+ stream.Flush(flushToDisk: true);
+ }
+
+ PrivateDataPermissions.RestrictFile(temporaryPath);
+ File.Move(temporaryPath, cachePath, overwrite: true);
+ PrivateDataPermissions.RestrictFile(cachePath);
+ }
+ catch (Exception ex) when (IsCacheException(ex))
+ {
+ // Caching is an optimization. A cache failure must not block update checks.
+ }
+ finally
+ {
+ TryDeleteTemporaryFile(temporaryPath);
+ }
+ }
+ }
+
+ private bool TryGetCachePath(
+ string assetUrl,
+ string expectedSha256,
+ long expectedSize,
+ long maximumSize,
+ out string cachePath)
+ {
+ cachePath = string.Empty;
+ if (string.IsNullOrWhiteSpace(_cacheDirectory) ||
+ !Uri.TryCreate(assetUrl, UriKind.Absolute, out _) ||
+ !IsValidIdentity(expectedSha256, expectedSize, maximumSize))
+ {
+ return false;
+ }
+
+ string identity = string.Create(
+ System.Globalization.CultureInfo.InvariantCulture,
+ $"{assetUrl}|{expectedSha256.ToLowerInvariant()}|{expectedSize}");
+ string fileName = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)))
+ .ToLowerInvariant() + ".json";
+ cachePath = Path.Combine(_cacheDirectory, fileName);
+ return true;
+ }
+
+ private static bool IsExpectedPayload(
+ byte[] payload,
+ string expectedSha256,
+ long expectedSize,
+ long maximumSize)
+ {
+ if (!IsValidIdentity(expectedSha256, expectedSize, maximumSize) ||
+ payload.LongLength != expectedSize)
+ {
+ return false;
+ }
+
+ byte[] expectedHash = Convert.FromHexString(expectedSha256);
+ byte[] actualHash = SHA256.HashData(payload);
+ return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
+ }
+
+ private static bool IsValidIdentity(string expectedSha256, long expectedSize, long maximumSize) =>
+ expectedSize > 0 &&
+ expectedSize <= maximumSize &&
+ expectedSize <= int.MaxValue &&
+ maximumSize > 0 &&
+ !string.IsNullOrWhiteSpace(expectedSha256) &&
+ expectedSha256.Length == 64 &&
+ expectedSha256.All(Uri.IsHexDigit);
+
+ private static bool IsLinkedFile(string path)
+ {
+ try
+ {
+ return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
+ }
+ catch (Exception ex) when (IsCacheException(ex))
+ {
+ return true;
+ }
+ }
+
+ private static void TryDeleteTemporaryFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path) && !IsLinkedFile(path))
+ File.Delete(path);
+ }
+ catch (Exception ex) when (IsCacheException(ex))
+ {
+ }
+ }
+
+ private static bool IsCacheException(Exception ex) =>
+ ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException;
+
+ private static string ResolveDefaultCacheDirectory()
+ {
+ string localData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return string.IsNullOrWhiteSpace(localData)
+ ? string.Empty
+ : Path.Combine(localData, "VaultSync", "cache", "release-assets");
+ }
+ }
+}
diff --git a/tests/VaultSync.Core.Tests/VerifiedReleaseAssetCacheTests.cs b/tests/VaultSync.Core.Tests/VerifiedReleaseAssetCacheTests.cs
new file mode 100644
index 00000000..4ca5d6c5
--- /dev/null
+++ b/tests/VaultSync.Core.Tests/VerifiedReleaseAssetCacheTests.cs
@@ -0,0 +1,104 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using VaultSync.Core.Tests.TestSupport;
+using VaultSync.UI.Services;
+using Xunit;
+
+namespace VaultSync.Core.Tests;
+
+public sealed class VerifiedReleaseAssetCacheTests
+{
+ private const string AssetUrl =
+ "https://github.com/ATAC-Helicopter/VaultSync/releases/download/v1.8.7/vaultsync-release-manifest.json";
+
+ [Fact]
+ public void VerifiedPayloadSurvivesCacheRecreation()
+ {
+ using var directory = new TempDirectory();
+ byte[] payload = Encoding.UTF8.GetBytes("{\"schemaVersion\":1}");
+ string hash = Sha256(payload);
+ var writer = new VerifiedReleaseAssetCache(directory.Path);
+
+ writer.Write(AssetUrl, hash, payload.LongLength, 1024, payload);
+
+ var reader = new VerifiedReleaseAssetCache(directory.Path);
+ Assert.True(reader.TryRead(AssetUrl, hash, payload.LongLength, 1024, out byte[] cached));
+ Assert.Equal(payload, cached);
+ Assert.Single(Directory.EnumerateFiles(directory.Path, "*.json"));
+ }
+
+ [Fact]
+ public void CacheIdentityIncludesUrlDigestAndSize()
+ {
+ using var directory = new TempDirectory();
+ byte[] payload = Encoding.UTF8.GetBytes("{\"release\":\"1.8.7\"}");
+ string hash = Sha256(payload);
+ var cache = new VerifiedReleaseAssetCache(directory.Path);
+ cache.Write(AssetUrl, hash, payload.LongLength, 1024, payload);
+
+ Assert.False(cache.TryRead(AssetUrl + ".other", hash, payload.LongLength, 1024, out _));
+ Assert.False(cache.TryRead(AssetUrl, new string('0', 64), payload.LongLength, 1024, out _));
+ Assert.False(cache.TryRead(AssetUrl, hash, payload.LongLength + 1, 1024, out _));
+ }
+
+ [Fact]
+ public void TamperedCacheEntryIsRejected()
+ {
+ using var directory = new TempDirectory();
+ byte[] payload = Encoding.UTF8.GetBytes("{\"trusted\":true}");
+ string hash = Sha256(payload);
+ var cache = new VerifiedReleaseAssetCache(directory.Path);
+ cache.Write(AssetUrl, hash, payload.LongLength, 1024, payload);
+ string cachePath = Assert.Single(Directory.EnumerateFiles(directory.Path, "*.json"));
+ File.WriteAllBytes(cachePath, Encoding.UTF8.GetBytes("{\"trusted\":fals}"));
+
+ Assert.False(cache.TryRead(AssetUrl, hash, payload.LongLength, 1024, out _));
+ }
+
+ [Theory]
+ [InlineData("not-a-url", 64, 10, 1024)]
+ [InlineData(AssetUrl, 63, 10, 1024)]
+ [InlineData(AssetUrl, 64, 0, 1024)]
+ [InlineData(AssetUrl, 64, 1025, 1024)]
+ public void InvalidCacheIdentityIsIgnored(string url, int hashLength, long size, long maximumSize)
+ {
+ using var directory = new TempDirectory();
+ var cache = new VerifiedReleaseAssetCache(directory.Path);
+ var payload = new byte[10];
+
+ cache.Write(url, new string('a', hashLength), size, maximumSize, payload);
+
+ Assert.False(cache.TryRead(url, new string('a', hashLength), size, maximumSize, out _));
+ Assert.Empty(Directory.EnumerateFileSystemEntries(directory.Path));
+ }
+
+ [Fact]
+ public void LinkedCacheEntryIsRejectedWithoutChangingItsTarget()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ using var directory = new TempDirectory();
+ using var targetDirectory = new TempDirectory();
+ byte[] payload = Encoding.UTF8.GetBytes("{\"trusted\":true}");
+ string hash = Sha256(payload);
+ var cache = new VerifiedReleaseAssetCache(directory.Path);
+ cache.Write(AssetUrl, hash, payload.LongLength, 1024, payload);
+ string cachePath = Assert.Single(Directory.EnumerateFiles(directory.Path, "*.json"));
+ File.Delete(cachePath);
+ string targetPath = Path.Combine(targetDirectory.Path, "target.json");
+ File.WriteAllText(targetPath, "external");
+ File.CreateSymbolicLink(cachePath, targetPath);
+
+ cache.Write(AssetUrl, hash, payload.LongLength, 1024, payload);
+
+ Assert.False(cache.TryRead(AssetUrl, hash, payload.LongLength, 1024, out _));
+ Assert.Equal("external", File.ReadAllText(targetPath));
+ }
+
+ private static string Sha256(byte[] payload) =>
+ Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
+}
From 221a95ef04d6a1849c2b0cd5dfe5a87c7e70b430 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 17:54:19 +0200
Subject: [PATCH 40/54] Make roadmap synchronization contract-safe
---
CHANGELOG.md | 1 +
ROADMAP.md | 9 +-
docs/RELEASE_1.8.7.md | 3 +
scripts/roadmap_sync.py | 295 ++++++++++++++++++
scripts/sync_project_descriptions.ps1 | 141 ++-------
.../scripts/fixtures/roadmap_sync/items.json | 39 +++
.../scripts/fixtures/roadmap_sync/roadmap.md | 13 +
tests/scripts/test_roadmap_sync.py | 93 ++++++
8 files changed, 473 insertions(+), 121 deletions(-)
create mode 100644 scripts/roadmap_sync.py
create mode 100644 tests/scripts/fixtures/roadmap_sync/items.json
create mode 100644 tests/scripts/fixtures/roadmap_sync/roadmap.md
create mode 100644 tests/scripts/test_roadmap_sync.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2ddd8e9..973d4a8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@
- [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, 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.
- [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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 538664a7..6194e465 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -45,6 +45,12 @@ Recovery Horizon (`1.9`) release families.
The checkbox is the delivery state. GitHub Project status, milestone, labels,
assignee, and dates mirror this file rather than defining a second roadmap.
+Before synchronizing descriptions, run
+`pwsh scripts/sync_project_descriptions.ps1 -ProjectNumber 7 -DryRun` and review
+the structured change report. The synchronizer reconstructs wrapped roadmap
+titles for matching, preserves manually maintained issue contracts, and writes
+only bodies carrying its `Synced from ROADMAP.md` ownership marker.
+
## Product arc
| Family | Name | Product question |
@@ -469,10 +475,11 @@ that exists only on the release branch or remains planned.
### Confirmed defects entering 1.8.7
-- [ ] `BUG-18098` `P1` Preserve complete wrapped roadmap ticket titles, scope,
+- [x] `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.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
- [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
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 6037b03f..55ad0d37 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -73,6 +73,9 @@ assumed and must be approved explicitly if the release needs one.
- Canonical and platform patch manifests now use an immutable, digest-verified
on-disk cache, preventing routine update checks and application restarts from
inflating GitHub JSON asset download counts (`BUG-18108`).
+- Roadmap-to-GitHub synchronization now reconstructs wrapped ticket contracts,
+ preserves manually maintained issue bodies, and provides an exact structured
+ dry run before any Project or issue write (`BUG-18098`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/scripts/roadmap_sync.py b/scripts/roadmap_sync.py
new file mode 100644
index 00000000..9a71049d
--- /dev/null
+++ b/scripts/roadmap_sync.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+"""Safely synchronize roadmap ticket contracts into GitHub Project items."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+
+TICKET_PATTERN = re.compile(
+ r"^\s*-\s+\[[xX ]\]\s+`?(?P(?:VS|ISS|BUG|REL)-\d+)`?\s*[:\-]?\s*(?P.+?)\s*$"
+)
+PRIORITY_PATTERN = re.compile(r"^(?:`?P[0-2]`?\s+)")
+HEADER_PATTERN = re.compile(r"^\s*#+\s+(.+?)\s*$")
+MANAGED_BODY_PREFIX = "Synced from ROADMAP.md"
+
+
+@dataclass(frozen=True)
+class RoadmapEntry:
+ ticket_id: str
+ title: str
+ section: str
+ description: str
+ completed: bool
+
+
+@dataclass(frozen=True)
+class PlannedChange:
+ item_id: str
+ content_type: str
+ issue_number: int | None
+ old_title: str
+ new_title: str
+ title_changed: bool
+ body_action: str
+ old_body: str
+ new_body: str
+
+
+def normalize_title(title: str | None) -> str:
+ normalized = re.sub(r"\s+", " ", title or "").strip()
+ return normalized[:-1] if normalized.endswith(".") else normalized
+
+
+def parse_roadmap(text: str) -> dict[str, RoadmapEntry]:
+ entries: dict[str, RoadmapEntry] = {}
+ current_section = ""
+ current_id: str | None = None
+ title_parts: list[str] = []
+ body_lines: list[str] = []
+ completed = False
+ collecting_title = False
+
+ def flush() -> None:
+ nonlocal current_id, title_parts, body_lines, completed, collecting_title
+ if current_id is None:
+ return
+ title_text = normalize_title(" ".join(title_parts))
+ full_title = normalize_title(f"{current_id}: {title_text}")
+ description = "\n".join(line for line in body_lines if line.strip()).strip()
+ entries[current_id] = RoadmapEntry(
+ ticket_id=current_id,
+ title=full_title,
+ section=current_section,
+ description=description,
+ completed=completed,
+ )
+ current_id = None
+ title_parts = []
+ body_lines = []
+ completed = False
+ collecting_title = False
+
+ for raw_line in text.splitlines():
+ header = HEADER_PATTERN.match(raw_line)
+ if header:
+ flush()
+ current_section = header.group(1).strip()
+ continue
+
+ ticket = TICKET_PATTERN.match(raw_line)
+ if ticket:
+ flush()
+ current_id = ticket.group("id")
+ rest = PRIORITY_PATTERN.sub("", ticket.group("rest")).strip()
+ title_parts = [rest]
+ completed = "[x]" in raw_line.lower()
+ collecting_title = True
+ continue
+
+ if current_id is None:
+ continue
+
+ trimmed = raw_line.strip()
+ if not trimmed:
+ collecting_title = False
+ continue
+
+ is_nested_item = bool(re.match(r"^(?:[-*+]\s+|\d+[.)]\s+)", trimmed))
+ if collecting_title and raw_line[:1].isspace() and not is_nested_item:
+ title_parts.append(trimmed)
+ continue
+
+ collecting_title = False
+ if raw_line[:1].isspace():
+ body_lines.append(_remove_ticket_indent(raw_line))
+
+ flush()
+ return entries
+
+
+def _remove_ticket_indent(line: str) -> str:
+ if line.startswith(" "):
+ return line[2:].rstrip()
+ if line.startswith("\t"):
+ return line[1:].rstrip()
+ return line.rstrip()
+
+
+def build_managed_body(entry: RoadmapEntry, item: dict[str, Any]) -> str:
+ values = {
+ "status": item.get("status") or "Todo",
+ "priority": item.get("priority") or "N/A",
+ "release": item.get("release") or "1.9.x",
+ "area": item.get("area") or "Core",
+ }
+ lines = [
+ MANAGED_BODY_PREFIX,
+ f"Section: {entry.section}",
+ f"Status: {values['status']}",
+ f"Priority: {values['priority']}",
+ f"Release: {values['release']}",
+ f"Area: {values['area']}",
+ "",
+ "Description:",
+ entry.description,
+ ]
+ return "\n".join(lines).rstrip()
+
+
+def plan_changes(items: Iterable[dict[str, Any]], index: dict[str, RoadmapEntry]) -> list[PlannedChange]:
+ changes: list[PlannedChange] = []
+ for item in items:
+ content = item.get("content") or {}
+ content_type = content.get("type") or ""
+ if content_type not in {"Issue", "DraftIssue"}:
+ continue
+
+ old_title = str(item.get("title") or content.get("title") or "")
+ ticket_match = re.search(r"(?:VS|ISS|BUG|REL)-\d+", old_title)
+ if ticket_match is None:
+ continue
+ entry = index.get(ticket_match.group(0))
+ if entry is None:
+ continue
+
+ old_body = str(content.get("body") or "")
+ generated_body = build_managed_body(entry, item)
+ body_is_managed = not old_body.strip() or old_body.startswith(MANAGED_BODY_PREFIX)
+ generated_is_shorter = bool(old_body) and len(generated_body) < len(old_body)
+ if not body_is_managed:
+ new_body = old_body
+ body_action = "preserve-manual"
+ elif generated_is_shorter:
+ new_body = old_body
+ body_action = "preserve-longer"
+ else:
+ new_body = generated_body
+ body_action = "update" if old_body != generated_body else "unchanged"
+ # Project titles can intentionally be shorter than roadmap prose. The
+ # reconstructed title is the matching contract, not authorization for a
+ # bulk rename of manually curated GitHub issues.
+ title_changed = False
+ if body_action in {"unchanged", "preserve-manual", "preserve-longer"}:
+ continue
+
+ changes.append(
+ PlannedChange(
+ item_id=str(item.get("id") or content.get("id") or ""),
+ content_type=content_type,
+ issue_number=content.get("number"),
+ old_title=old_title,
+ new_title=old_title,
+ title_changed=title_changed,
+ body_action=body_action,
+ old_body=old_body,
+ new_body=new_body,
+ )
+ )
+ return changes
+
+
+def run_gh(arguments: list[str]) -> str:
+ completed = subprocess.run(
+ ["gh", *arguments],
+ check=True,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ return completed.stdout
+
+
+def load_items(args: argparse.Namespace) -> tuple[str, list[dict[str, Any]]]:
+ if args.items_snapshot_path:
+ if not args.dry_run:
+ raise ValueError("--items-snapshot-path is allowed only with --dry-run")
+ snapshot = json.loads(Path(args.items_snapshot_path).read_text(encoding="utf-8-sig"))
+ return "snapshot", list(snapshot.get("items") or [])
+
+ project = json.loads(
+ run_gh(["project", "view", str(args.project_number), "--owner", args.owner, "--format", "json"])
+ )
+ item_data = json.loads(
+ run_gh(
+ [
+ "project",
+ "item-list",
+ str(args.project_number),
+ "--owner",
+ args.owner,
+ "--limit",
+ "1000",
+ "--format",
+ "json",
+ ]
+ )
+ )
+ return str(project["id"]), list(item_data.get("items") or [])
+
+
+def apply_change(change: PlannedChange, owner: str, project_id: str) -> None:
+ if change.content_type == "Issue" and change.issue_number:
+ arguments = ["issue", "edit", str(change.issue_number), "--repo", f"{owner}/VaultSync"]
+ if change.title_changed:
+ arguments.extend(["--title", change.new_title])
+ if change.body_action == "update":
+ arguments.extend(["--body", change.new_body])
+ run_gh(arguments)
+ return
+
+ if change.content_type == "DraftIssue" and change.item_id:
+ arguments = ["project", "item-edit", "--id", change.item_id, "--project-id", project_id]
+ if change.title_changed:
+ arguments.extend(["--title", change.new_title])
+ if change.body_action == "update":
+ arguments.extend(["--body", change.new_body])
+ run_gh(arguments)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--owner", default="ATAC-Helicopter")
+ parser.add_argument("--project-number", type=int, default=1)
+ parser.add_argument("--roadmap-path", default="ROADMAP.md")
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--items-snapshot-path", default="")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ roadmap_text = Path(args.roadmap_path).read_text(encoding="utf-8-sig")
+ index = parse_roadmap(roadmap_text)
+ project_id, items = load_items(args)
+ changes = plan_changes(items, index)
+
+ if args.dry_run:
+ print(
+ json.dumps(
+ {
+ "dryRun": True,
+ "indexed": len(index),
+ "changes": [asdict(change) for change in changes],
+ "unchanged": len(items) - len(changes),
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ return 0
+
+ for change in changes:
+ apply_change(change, args.owner, project_id)
+ print(f"Descriptions sync complete. updated={len(changes)} indexed={len(index)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/sync_project_descriptions.ps1 b/scripts/sync_project_descriptions.ps1
index 0f31f1cc..ff54cd90 100644
--- a/scripts/sync_project_descriptions.ps1
+++ b/scripts/sync_project_descriptions.ps1
@@ -1,132 +1,33 @@
param(
[string]$Owner = 'ATAC-Helicopter',
[int]$ProjectNumber = 1,
- [string]$RoadmapPath = 'ROADMAP.md'
+ [string]$RoadmapPath = 'ROADMAP.md',
+ [switch]$DryRun,
+ [string]$ItemsSnapshotPath = ''
)
$ErrorActionPreference = 'Stop'
-function Normalize-Title([string]$title) {
- if ([string]::IsNullOrWhiteSpace($title)) { return '' }
- $t = ($title -replace '\s+', ' ').Trim()
- if ($t.EndsWith('.')) { $t = $t.Substring(0, $t.Length - 1) }
- return $t
+$python = Get-Command python3 -ErrorAction SilentlyContinue
+if (-not $python) {
+ $python = Get-Command python -ErrorAction Stop
}
-function Build-RoadmapIndex([string]$path) {
- $index = @{}
- $lines = Get-Content $path
- $currentSection = ''
- $currentTitle = $null
- $buffer = New-Object System.Collections.Generic.List[string]
- $lastHeader = ''
-
- function Flush-Current {
- param($titleRef, $bufferRef, $sectionRef)
- if ([string]::IsNullOrWhiteSpace($titleRef)) { return }
- $k = Normalize-Title $titleRef
- $desc = ($bufferRef | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "`n"
- if ([string]::IsNullOrWhiteSpace($desc)) { return }
- $index[$k] = @{
- section = $sectionRef
- description = $desc.Trim()
- }
- }
-
- foreach ($line in $lines) {
- if ($line -match '^\s*#+\s+(.+?)\s*$') {
- $headerText = $matches[1].Trim()
- if ($headerText -ne $lastHeader) {
- $currentSection = $headerText
- $lastHeader = $headerText
- }
- continue
- }
-
- if ($line -match '^\s*-\s+\[[xX\ ]\]\s+`?(VS|ISS|BUG|REL)-\d+`?\s*[:\-]?\s*(.+?)\s*$') {
- Flush-Current -titleRef $currentTitle -bufferRef $buffer -sectionRef $currentSection
- $buffer.Clear()
- $rest = $matches[2].Trim()
- # Optional priority marker in roadmap ticket title, e.g. `P1` or P1
- $rest = ($rest -replace '^(?:`?P[0-2]`?\s+)', '').Trim()
- $ticketId = ([regex]::Match($line, '(VS|ISS|BUG|REL)-\d+')).Value
- $currentTitle = Normalize-Title "${ticketId}: $rest"
- continue
- }
-
- if ($line -match '^\s{2,}[-0-9A-Za-z`].*') {
- if (-not [string]::IsNullOrWhiteSpace($currentTitle)) {
- $buffer.Add(($line.Trim()))
- }
- }
- }
-
- Flush-Current -titleRef $currentTitle -bufferRef $buffer -sectionRef $currentSection
- return $index
+$scriptPath = Join-Path $PSScriptRoot 'roadmap_sync.py'
+$arguments = @(
+ $scriptPath,
+ '--owner', $Owner,
+ '--project-number', $ProjectNumber,
+ '--roadmap-path', $RoadmapPath
+)
+if ($DryRun) {
+ $arguments += '--dry-run'
}
-
-$roadmapIndex = Build-RoadmapIndex -path $RoadmapPath
-$project = gh project view $ProjectNumber --owner $Owner --format json | ConvertFrom-Json
-$projectId = $project.id
-$items = (gh project item-list $ProjectNumber --owner $Owner --limit 1000 --format json | ConvertFrom-Json).items
-
-$updated = 0
-$skipped = 0
-
-foreach ($item in $items) {
- $title = Normalize-Title $item.title
- $matchKey = $null
-
- if ($roadmapIndex.ContainsKey($title)) {
- $matchKey = $title
- } else {
- $idMatch = [regex]::Match($title, '(VS|ISS|BUG|REL)-\d+')
- if ($idMatch.Success) {
- $ticketId = $idMatch.Value
- $candidate = $roadmapIndex.Keys | Where-Object { $_ -like "${ticketId}:*" } | Select-Object -First 1
- if ($candidate) { $matchKey = $candidate }
- }
- }
-
- if (-not $matchKey) {
- $skipped++
- continue
- }
-
- $info = $roadmapIndex[$matchKey]
- $status = if ([string]::IsNullOrWhiteSpace($item.status)) { 'Todo' } else { $item.status }
- $priority = if ([string]::IsNullOrWhiteSpace($item.priority)) { 'N/A' } else { $item.priority }
- $release = if ([string]::IsNullOrWhiteSpace($item.release)) { '1.9.x' } else { $item.release }
- $area = if ([string]::IsNullOrWhiteSpace($item.area)) { 'Core' } else { $item.area }
-
- $body = @(
- 'Synced from ROADMAP.md'
- "Section: $($info.section)"
- "Status: $status"
- "Priority: $priority"
- "Release: $release"
- "Area: $area"
- ''
- 'Description:'
- $info.description
- ) -join "`n"
-
- if ($item.content.type -eq 'DraftIssue') {
- gh project item-edit --id $item.content.id --project-id $projectId --title $title --body $body | Out-Null
- $updated++
- continue
- }
-
- if ($item.content.type -eq 'Issue') {
- $issueNumber = $item.content.number
- if ($issueNumber) {
- gh issue edit $issueNumber --repo "$Owner/VaultSync" --title $title --body $body | Out-Null
- $updated++
- continue
- }
- }
-
- $skipped++
+if (-not [string]::IsNullOrWhiteSpace($ItemsSnapshotPath)) {
+ $arguments += @('--items-snapshot-path', $ItemsSnapshotPath)
}
-Write-Host "Descriptions sync complete. updated=$updated skipped=$skipped indexed=$($roadmapIndex.Count)"
+& $python.Source @arguments
+if ($LASTEXITCODE -ne 0) {
+ throw "Roadmap description sync failed with exit code $LASTEXITCODE."
+}
diff --git a/tests/scripts/fixtures/roadmap_sync/items.json b/tests/scripts/fixtures/roadmap_sync/items.json
new file mode 100644
index 00000000..ed39cbad
--- /dev/null
+++ b/tests/scripts/fixtures/roadmap_sync/items.json
@@ -0,0 +1,39 @@
+{
+ "items": [
+ {
+ "id": "item-managed",
+ "title": "VS-2001: Preserve a complete wrapped ticket title across",
+ "status": "Todo",
+ "priority": "P1",
+ "release": "1.8.x",
+ "area": "Release",
+ "content": {
+ "type": "Issue",
+ "number": 2001,
+ "body": "Synced from ROADMAP.md\nDescription:\nfragment"
+ }
+ },
+ {
+ "id": "item-manual",
+ "title": "BUG-2002: Preserve completed item state",
+ "status": "Done",
+ "priority": "P0",
+ "release": "1.8.x",
+ "area": "Core",
+ "content": {
+ "type": "Issue",
+ "number": 2002,
+ "body": "## Goal\n\nThis manually maintained contract must remain byte-for-byte unchanged."
+ }
+ },
+ {
+ "id": "item-draft",
+ "title": "REL-2003: Consecutive ticket parsing",
+ "content": {
+ "id": "draft-content-2003",
+ "type": "DraftIssue",
+ "body": ""
+ }
+ }
+ ]
+}
diff --git a/tests/scripts/fixtures/roadmap_sync/roadmap.md b/tests/scripts/fixtures/roadmap_sync/roadmap.md
new file mode 100644
index 00000000..26878a15
--- /dev/null
+++ b/tests/scripts/fixtures/roadmap_sync/roadmap.md
@@ -0,0 +1,13 @@
+# Fixture roadmap
+
+## Wrapped contracts
+
+- [ ] `VS-2001` `P1` Preserve a complete wrapped ticket title across
+ every physical line in the roadmap.
+ - Scope:
+ - Keep nested scope details attached.
+ - Acceptance: parser output contains the complete title and contract.
+- [x] `BUG-2002` `P0` Preserve completed item state.
+ - Acceptance: completed tickets remain indexable.
+- [ ] REL-2003 Consecutive ticket parsing
+ - Non-goal: rewriting manually maintained issue contracts.
diff --git a/tests/scripts/test_roadmap_sync.py b/tests/scripts/test_roadmap_sync.py
new file mode 100644
index 00000000..7d069294
--- /dev/null
+++ b/tests/scripts/test_roadmap_sync.py
@@ -0,0 +1,93 @@
+import importlib.util
+import json
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+SCRIPT_PATH = ROOT / "scripts" / "roadmap_sync.py"
+FIXTURE_ROOT = ROOT / "tests" / "scripts" / "fixtures" / "roadmap_sync"
+SPEC = importlib.util.spec_from_file_location("roadmap_sync", SCRIPT_PATH)
+roadmap_sync = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = roadmap_sync
+SPEC.loader.exec_module(roadmap_sync)
+
+
+class RoadmapSyncTests(unittest.TestCase):
+ def test_parser_reconstructs_wrapped_titles_and_nested_contracts(self):
+ index = roadmap_sync.parse_roadmap(
+ (FIXTURE_ROOT / "roadmap.md").read_text(encoding="utf-8")
+ )
+
+ wrapped = index["VS-2001"]
+ self.assertEqual(
+ "VS-2001: Preserve a complete wrapped ticket title across every physical line in the roadmap",
+ wrapped.title,
+ )
+ self.assertIn("- Scope:\n - Keep nested scope details attached.", wrapped.description)
+ self.assertIn("- Acceptance:", wrapped.description)
+ self.assertTrue(index["BUG-2002"].completed)
+ self.assertEqual("REL-2003: Consecutive ticket parsing", index["REL-2003"].title)
+
+ def test_planner_updates_managed_bodies_and_preserves_manual_contracts(self):
+ index = roadmap_sync.parse_roadmap(
+ (FIXTURE_ROOT / "roadmap.md").read_text(encoding="utf-8")
+ )
+ items = json.loads((FIXTURE_ROOT / "items.json").read_text(encoding="utf-8"))["items"]
+
+ changes = roadmap_sync.plan_changes(items, index)
+
+ managed = next(change for change in changes if change.issue_number == 2001)
+ self.assertFalse(managed.title_changed)
+ self.assertEqual("update", managed.body_action)
+ self.assertIn("Keep nested scope details attached", managed.new_body)
+ manual_changes = [change for change in changes if change.issue_number == 2002]
+ self.assertEqual([], manual_changes)
+ draft = next(change for change in changes if change.content_type == "DraftIssue")
+ self.assertEqual("update", draft.body_action)
+ self.assertIn("Non-goal", draft.new_body)
+
+ def test_planner_never_replaces_a_longer_managed_contract_with_a_fragment(self):
+ index = roadmap_sync.parse_roadmap(
+ (FIXTURE_ROOT / "roadmap.md").read_text(encoding="utf-8")
+ )
+ item = {
+ "id": "item-complete",
+ "title": "VS-2001: Existing concise issue title",
+ "content": {
+ "type": "Issue",
+ "number": 2001,
+ "body": roadmap_sync.MANAGED_BODY_PREFIX + "\n" + ("complete contract\n" * 100),
+ },
+ }
+
+ self.assertEqual([], roadmap_sync.plan_changes([item], index))
+
+ def test_offline_dry_run_reports_exact_changes_without_writing(self):
+ completed = subprocess.run(
+ [
+ sys.executable,
+ str(SCRIPT_PATH),
+ "--roadmap-path",
+ str(FIXTURE_ROOT / "roadmap.md"),
+ "--items-snapshot-path",
+ str(FIXTURE_ROOT / "items.json"),
+ "--dry-run",
+ ],
+ check=True,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ report = json.loads(completed.stdout)
+ self.assertTrue(report["dryRun"])
+ self.assertEqual(3, report["indexed"])
+ self.assertEqual(2, len(report["changes"]))
+ self.assertEqual({"Issue", "DraftIssue"}, {item["content_type"] for item in report["changes"]})
+
+
+if __name__ == "__main__":
+ unittest.main()
From f396ee69647b23749be911f69714b317307b9af1 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:07:24 +0200
Subject: [PATCH 41/54] Harden roadmap synchronization boundaries
---
CHANGELOG.md | 2 +-
docs/RELEASE_1.8.7.md | 5 +-
scripts/roadmap_sync.py | 319 +++++++++++-------
.../Services/VerifiedReleaseAssetCache.cs | 2 +-
tests/scripts/test_roadmap_sync.py | 113 +++++++
5 files changed, 318 insertions(+), 123 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 973d4a8e..72accf67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,7 @@
- [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, and an exact write-free dry-run report.
+- [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.
- [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.
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 55ad0d37..e7ec1380 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -74,8 +74,9 @@ assumed and must be approved explicitly if the release needs one.
on-disk cache, preventing routine update checks and application restarts from
inflating GitHub JSON asset download counts (`BUG-18108`).
- Roadmap-to-GitHub synchronization now reconstructs wrapped ticket contracts,
- preserves manually maintained issue bodies, and provides an exact structured
- dry run before any Project or issue write (`BUG-18098`).
+ 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`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
diff --git a/scripts/roadmap_sync.py b/scripts/roadmap_sync.py
index 9a71049d..6590efc2 100644
--- a/scripts/roadmap_sync.py
+++ b/scripts/roadmap_sync.py
@@ -6,18 +6,20 @@
import argparse
import json
import re
+import shutil
import subprocess
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterable
-TICKET_PATTERN = re.compile(
- r"^\s*-\s+\[[xX ]\]\s+`?(?P(?:VS|ISS|BUG|REL)-\d+)`?\s*[:\-]?\s*(?P.+?)\s*$"
-)
-PRIORITY_PATTERN = re.compile(r"^(?:`?P[0-2]`?\s+)")
-HEADER_PATTERN = re.compile(r"^\s*#+\s+(.+?)\s*$")
+TICKET_ID_PATTERN = re.compile(r"(?:VS|ISS|BUG|REL)-[0-9]+")
+OWNER_PATTERN = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})")
+PROJECT_ID_PATTERN = re.compile(r"PVT[A-Za-z0-9_-]+")
+ITEM_ID_PATTERN = re.compile(r"PVTI_[A-Za-z0-9_-]+")
MANAGED_BODY_PREFIX = "Synced from ROADMAP.md"
+REPOSITORY_NAME = "VaultSync"
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
@dataclass(frozen=True)
@@ -42,6 +44,16 @@ class PlannedChange:
new_body: str
+@dataclass
+class _ParserState:
+ section: str = ""
+ ticket_id: str | None = None
+ title_parts: list[str] = field(default_factory=list)
+ body_lines: list[str] = field(default_factory=list)
+ completed: bool = False
+ collecting_title: bool = False
+
+
def normalize_title(title: str | None) -> str:
normalized = re.sub(r"\s+", " ", title or "").strip()
return normalized[:-1] if normalized.endswith(".") else normalized
@@ -49,69 +61,102 @@ def normalize_title(title: str | None) -> str:
def parse_roadmap(text: str) -> dict[str, RoadmapEntry]:
entries: dict[str, RoadmapEntry] = {}
- current_section = ""
- current_id: str | None = None
- title_parts: list[str] = []
- body_lines: list[str] = []
- completed = False
- collecting_title = False
-
- def flush() -> None:
- nonlocal current_id, title_parts, body_lines, completed, collecting_title
- if current_id is None:
- return
- title_text = normalize_title(" ".join(title_parts))
- full_title = normalize_title(f"{current_id}: {title_text}")
- description = "\n".join(line for line in body_lines if line.strip()).strip()
- entries[current_id] = RoadmapEntry(
- ticket_id=current_id,
- title=full_title,
- section=current_section,
- description=description,
- completed=completed,
- )
- current_id = None
- title_parts = []
- body_lines = []
- completed = False
- collecting_title = False
+ state = _ParserState()
for raw_line in text.splitlines():
- header = HEADER_PATTERN.match(raw_line)
- if header:
- flush()
- current_section = header.group(1).strip()
+ header = _parse_header(raw_line)
+ if header is not None:
+ _flush_entry(entries, state)
+ state.section = header
continue
- ticket = TICKET_PATTERN.match(raw_line)
- if ticket:
- flush()
- current_id = ticket.group("id")
- rest = PRIORITY_PATTERN.sub("", ticket.group("rest")).strip()
- title_parts = [rest]
- completed = "[x]" in raw_line.lower()
- collecting_title = True
+ ticket = _parse_ticket(raw_line)
+ if ticket is not None:
+ _flush_entry(entries, state)
+ state.ticket_id, title, state.completed = ticket
+ state.title_parts = [title]
+ state.collecting_title = True
continue
- if current_id is None:
+ if state.ticket_id is None:
continue
+ _append_continuation(state, raw_line)
- trimmed = raw_line.strip()
- if not trimmed:
- collecting_title = False
- continue
+ _flush_entry(entries, state)
+ return entries
- is_nested_item = bool(re.match(r"^(?:[-*+]\s+|\d+[.)]\s+)", trimmed))
- if collecting_title and raw_line[:1].isspace() and not is_nested_item:
- title_parts.append(trimmed)
- continue
- collecting_title = False
- if raw_line[:1].isspace():
- body_lines.append(_remove_ticket_indent(raw_line))
+def _parse_header(raw_line: str) -> str | None:
+ stripped = raw_line.strip()
+ if not stripped.startswith("#"):
+ return None
+ header = stripped.lstrip("#").strip()
+ return header or None
- flush()
- return entries
+
+def _parse_ticket(raw_line: str) -> tuple[str, str, bool] | None:
+ stripped = raw_line.strip()
+ if len(stripped) < 7 or not stripped.startswith("- [") or stripped[4:6] != "] ":
+ return None
+ marker = stripped[3]
+ if marker not in "xX ":
+ return None
+
+ identifier, separator, remainder = stripped[6:].partition(" ")
+ identifier = identifier.rstrip(":-").strip("`")
+ if not separator or TICKET_ID_PATTERN.fullmatch(identifier) is None:
+ return None
+ title = _remove_priority(remainder.strip())
+ if not title:
+ return None
+ return identifier, title, marker.lower() == "x"
+
+
+def _remove_priority(text: str) -> str:
+ first, separator, remainder = text.partition(" ")
+ if first.strip("`") in {"P0", "P1", "P2"} and separator:
+ return remainder.strip()
+ return text
+
+
+def _flush_entry(entries: dict[str, RoadmapEntry], state: _ParserState) -> None:
+ if state.ticket_id is None:
+ return
+ title_text = normalize_title(" ".join(state.title_parts))
+ description = "\n".join(line for line in state.body_lines if line.strip()).strip()
+ entries[state.ticket_id] = RoadmapEntry(
+ ticket_id=state.ticket_id,
+ title=normalize_title(f"{state.ticket_id}: {title_text}"),
+ section=state.section,
+ description=description,
+ completed=state.completed,
+ )
+ state.ticket_id = None
+ state.title_parts = []
+ state.body_lines = []
+ state.completed = False
+ state.collecting_title = False
+
+
+def _append_continuation(state: _ParserState, raw_line: str) -> None:
+ trimmed = raw_line.strip()
+ if not trimmed:
+ state.collecting_title = False
+ return
+ indented = raw_line[:1].isspace()
+ if state.collecting_title and indented and not _is_nested_item(trimmed):
+ state.title_parts.append(trimmed)
+ return
+ state.collecting_title = False
+ if indented:
+ state.body_lines.append(_remove_ticket_indent(raw_line))
+
+
+def _is_nested_item(text: str) -> bool:
+ if text.startswith(("- ", "* ", "+ ")):
+ return True
+ prefix, separator, _ = text.partition(" ")
+ return bool(separator and prefix[:-1].isdigit() and prefix[-1:] in {".", ")"})
def _remove_ticket_indent(line: str) -> str:
@@ -144,61 +189,56 @@ def build_managed_body(entry: RoadmapEntry, item: dict[str, Any]) -> str:
def plan_changes(items: Iterable[dict[str, Any]], index: dict[str, RoadmapEntry]) -> list[PlannedChange]:
- changes: list[PlannedChange] = []
- for item in items:
- content = item.get("content") or {}
- content_type = content.get("type") or ""
- if content_type not in {"Issue", "DraftIssue"}:
- continue
-
- old_title = str(item.get("title") or content.get("title") or "")
- ticket_match = re.search(r"(?:VS|ISS|BUG|REL)-\d+", old_title)
- if ticket_match is None:
- continue
- entry = index.get(ticket_match.group(0))
- if entry is None:
- continue
-
- old_body = str(content.get("body") or "")
- generated_body = build_managed_body(entry, item)
- body_is_managed = not old_body.strip() or old_body.startswith(MANAGED_BODY_PREFIX)
- generated_is_shorter = bool(old_body) and len(generated_body) < len(old_body)
- if not body_is_managed:
- new_body = old_body
- body_action = "preserve-manual"
- elif generated_is_shorter:
- new_body = old_body
- body_action = "preserve-longer"
- else:
- new_body = generated_body
- body_action = "update" if old_body != generated_body else "unchanged"
- # Project titles can intentionally be shorter than roadmap prose. The
- # reconstructed title is the matching contract, not authorization for a
- # bulk rename of manually curated GitHub issues.
- title_changed = False
- if body_action in {"unchanged", "preserve-manual", "preserve-longer"}:
- continue
-
- changes.append(
- PlannedChange(
- item_id=str(item.get("id") or content.get("id") or ""),
- content_type=content_type,
- issue_number=content.get("number"),
- old_title=old_title,
- new_title=old_title,
- title_changed=title_changed,
- body_action=body_action,
- old_body=old_body,
- new_body=new_body,
- )
- )
- return changes
+ planned = (_plan_item_change(item, index) for item in items)
+ return [change for change in planned if change is not None]
+
+
+def _plan_item_change(
+ item: dict[str, Any], index: dict[str, RoadmapEntry]
+) -> PlannedChange | None:
+ content = item.get("content") or {}
+ content_type = content.get("type") or ""
+ if content_type not in {"Issue", "DraftIssue"}:
+ return None
+
+ old_title = str(item.get("title") or content.get("title") or "")
+ ticket_match = TICKET_ID_PATTERN.search(old_title)
+ entry = index.get(ticket_match.group(0)) if ticket_match else None
+ if entry is None:
+ return None
+
+ old_body = str(content.get("body") or "")
+ generated_body = build_managed_body(entry, item)
+ if old_body.strip() and not old_body.startswith(MANAGED_BODY_PREFIX):
+ return None
+ if old_body and len(generated_body) < len(old_body):
+ return None
+ if old_body == generated_body:
+ return None
+
+ # GitHub titles can intentionally be shorter than roadmap prose. A roadmap
+ # match authorizes managed-body repair, never a bulk title rewrite.
+ return PlannedChange(
+ item_id=str(item.get("id") or content.get("id") or ""),
+ content_type=content_type,
+ issue_number=content.get("number"),
+ old_title=old_title,
+ new_title=old_title,
+ title_changed=False,
+ body_action="update",
+ old_body=old_body,
+ new_body=generated_body,
+ )
def run_gh(arguments: list[str]) -> str:
+ executable = shutil.which("gh")
+ if executable is None:
+ raise RuntimeError("GitHub CLI (gh) is required")
completed = subprocess.run(
- ["gh", *arguments],
+ [executable, *arguments], # NOSONAR: fixed executable, no shell, validated identifiers.
check=True,
+ shell=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -210,20 +250,23 @@ def load_items(args: argparse.Namespace) -> tuple[str, list[dict[str, Any]]]:
if args.items_snapshot_path:
if not args.dry_run:
raise ValueError("--items-snapshot-path is allowed only with --dry-run")
- snapshot = json.loads(Path(args.items_snapshot_path).read_text(encoding="utf-8-sig"))
+ snapshot_path = resolve_input_path(args.items_snapshot_path)
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8-sig"))
return "snapshot", list(snapshot.get("items") or [])
+ owner = validate_owner(args.owner)
+ project_number = validate_project_number(args.project_number)
project = json.loads(
- run_gh(["project", "view", str(args.project_number), "--owner", args.owner, "--format", "json"])
+ run_gh(["project", "view", project_number, "--owner", owner, "--format", "json"])
)
item_data = json.loads(
run_gh(
[
"project",
"item-list",
- str(args.project_number),
+ project_number,
"--owner",
- args.owner,
+ owner,
"--limit",
"1000",
"--format",
@@ -231,12 +274,46 @@ def load_items(args: argparse.Namespace) -> tuple[str, list[dict[str, Any]]]:
]
)
)
- return str(project["id"]), list(item_data.get("items") or [])
+ project_id = str(project["id"])
+ if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
+ raise ValueError("GitHub returned an invalid project identifier")
+ return project_id, list(item_data.get("items") or [])
+
+
+def validate_owner(owner: str) -> str:
+ if OWNER_PATTERN.fullmatch(owner) is None:
+ raise ValueError("--owner must be a valid GitHub account name")
+ return owner
+
+
+def validate_project_number(project_number: int) -> str:
+ if project_number <= 0:
+ raise ValueError("--project-number must be positive")
+ return str(project_number)
+
+
+def resolve_input_path(raw_path: str) -> Path:
+ candidate = Path(raw_path)
+ if not candidate.is_absolute():
+ candidate = REPOSITORY_ROOT / candidate
+ resolved = candidate.resolve(strict=True) # NOSONAR: containment is enforced before use.
+ if not resolved.is_relative_to(REPOSITORY_ROOT) or not resolved.is_file():
+ raise ValueError("Input files must be regular files inside the repository")
+ return resolved
def apply_change(change: PlannedChange, owner: str, project_id: str) -> None:
if change.content_type == "Issue" and change.issue_number:
- arguments = ["issue", "edit", str(change.issue_number), "--repo", f"{owner}/VaultSync"]
+ validated_owner = validate_owner(owner)
+ if change.issue_number <= 0:
+ raise ValueError("Issue number must be positive")
+ arguments = [
+ "issue",
+ "edit",
+ str(change.issue_number),
+ "--repo",
+ f"{validated_owner}/{REPOSITORY_NAME}",
+ ]
if change.title_changed:
arguments.extend(["--title", change.new_title])
if change.body_action == "update":
@@ -245,6 +322,10 @@ def apply_change(change: PlannedChange, owner: str, project_id: str) -> None:
return
if change.content_type == "DraftIssue" and change.item_id:
+ if ITEM_ID_PATTERN.fullmatch(change.item_id) is None:
+ raise ValueError("Draft item identifier is invalid")
+ if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
+ raise ValueError("Project identifier is invalid")
arguments = ["project", "item-edit", "--id", change.item_id, "--project-id", project_id]
if change.title_changed:
arguments.extend(["--title", change.new_title])
@@ -263,9 +344,10 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
-def main() -> int:
+def main() -> None:
args = parse_args()
- roadmap_text = Path(args.roadmap_path).read_text(encoding="utf-8-sig")
+ roadmap_path = resolve_input_path(args.roadmap_path)
+ roadmap_text = roadmap_path.read_text(encoding="utf-8-sig")
index = parse_roadmap(roadmap_text)
project_id, items = load_items(args)
changes = plan_changes(items, index)
@@ -283,13 +365,12 @@ def main() -> int:
sort_keys=True,
)
)
- return 0
+ return
for change in changes:
apply_change(change, args.owner, project_id)
print(f"Descriptions sync complete. updated={len(changes)} indexed={len(index)}")
- return 0
if __name__ == "__main__":
- raise SystemExit(main())
+ main()
diff --git a/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs b/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
index 270c3c9f..23f7ed1f 100644
--- a/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
+++ b/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs
@@ -173,7 +173,6 @@ private static bool IsValidIdentity(string expectedSha256, long expectedSize, lo
expectedSize > 0 &&
expectedSize <= maximumSize &&
expectedSize <= int.MaxValue &&
- maximumSize > 0 &&
!string.IsNullOrWhiteSpace(expectedSha256) &&
expectedSha256.Length == 64 &&
expectedSha256.All(Uri.IsHexDigit);
@@ -199,6 +198,7 @@ private static void TryDeleteTemporaryFile(string path)
}
catch (Exception ex) when (IsCacheException(ex))
{
+ // Temporary-file cleanup is best effort; update checks must continue.
}
}
diff --git a/tests/scripts/test_roadmap_sync.py b/tests/scripts/test_roadmap_sync.py
index 7d069294..88dd51e1 100644
--- a/tests/scripts/test_roadmap_sync.py
+++ b/tests/scripts/test_roadmap_sync.py
@@ -1,9 +1,12 @@
+import argparse
import importlib.util
import json
import subprocess
import sys
+import tempfile
import unittest
from pathlib import Path
+from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
@@ -31,6 +34,24 @@ def test_parser_reconstructs_wrapped_titles_and_nested_contracts(self):
self.assertTrue(index["BUG-2002"].completed)
self.assertEqual("REL-2003: Consecutive ticket parsing", index["REL-2003"].title)
+ def test_parser_ignores_invalid_tickets_and_handles_numbered_contract_items(self):
+ index = roadmap_sync.parse_roadmap(
+ """
+# Safety
+- [!] BUG-1 Invalid marker
+- [ ] NOPE-2 Invalid identifier
+- [ ] BUG-3 P1 Valid title
+ 1. First acceptance criterion
+ continuation after a blank line
+
+ - Scope item
+"""
+ )
+
+ self.assertEqual(["BUG-3"], list(index))
+ self.assertIn("1. First acceptance criterion", index["BUG-3"].description)
+ self.assertIn("- Scope item", index["BUG-3"].description)
+
def test_planner_updates_managed_bodies_and_preserves_manual_contracts(self):
index = roadmap_sync.parse_roadmap(
(FIXTURE_ROOT / "roadmap.md").read_text(encoding="utf-8")
@@ -65,6 +86,98 @@ def test_planner_never_replaces_a_longer_managed_contract_with_a_fragment(self):
self.assertEqual([], roadmap_sync.plan_changes([item], index))
+ def test_planner_skips_nonissues_unknown_tickets_and_unchanged_managed_bodies(self):
+ entry = roadmap_sync.RoadmapEntry("BUG-1", "BUG-1: Fix", "Fixes", "Details", False)
+ managed_item = {
+ "id": "PVTI_item",
+ "title": "BUG-1: Fix",
+ "status": "Todo",
+ "priority": "N/A",
+ "release": "1.9.x",
+ "area": "Core",
+ "content": {"type": "Issue", "number": 1, "body": ""},
+ }
+ managed_item["content"]["body"] = roadmap_sync.build_managed_body(entry, managed_item)
+ items = [
+ {"title": "BUG-1", "content": {"type": "PullRequest"}},
+ {"title": "No ticket", "content": {"type": "Issue"}},
+ {"title": "BUG-999", "content": {"type": "Issue"}},
+ managed_item,
+ ]
+
+ self.assertEqual([], roadmap_sync.plan_changes(items, {"BUG-1": entry}))
+
+ def test_input_validation_rejects_unsafe_identifiers_and_external_paths(self):
+ self.assertEqual("ATAC-Helicopter", roadmap_sync.validate_owner("ATAC-Helicopter"))
+ self.assertEqual("7", roadmap_sync.validate_project_number(7))
+ with self.assertRaises(ValueError):
+ roadmap_sync.validate_owner("--help")
+ with self.assertRaises(ValueError):
+ roadmap_sync.validate_project_number(0)
+ with tempfile.NamedTemporaryFile() as external:
+ with self.assertRaises(ValueError):
+ roadmap_sync.resolve_input_path(external.name)
+
+ def test_snapshot_loading_is_dry_run_only(self):
+ args = argparse.Namespace(
+ items_snapshot_path=str(FIXTURE_ROOT / "items.json"),
+ dry_run=False,
+ )
+ with self.assertRaises(ValueError):
+ roadmap_sync.load_items(args)
+
+ args.dry_run = True
+ project_id, items = roadmap_sync.load_items(args)
+ self.assertEqual("snapshot", project_id)
+ self.assertEqual(3, len(items))
+
+ @mock.patch.object(roadmap_sync, "run_gh")
+ def test_live_loading_validates_github_results(self, run_gh):
+ run_gh.side_effect = [
+ json.dumps({"id": "PVT_project"}),
+ json.dumps({"items": [{"id": "PVTI_item"}]}),
+ ]
+ args = argparse.Namespace(
+ items_snapshot_path="",
+ dry_run=True,
+ owner="ATAC-Helicopter",
+ project_number=7,
+ )
+
+ project_id, items = roadmap_sync.load_items(args)
+
+ self.assertEqual("PVT_project", project_id)
+ self.assertEqual([{"id": "PVTI_item"}], items)
+ self.assertEqual(2, run_gh.call_count)
+
+ @mock.patch.object(roadmap_sync, "run_gh")
+ def test_apply_change_uses_structured_issue_and_draft_arguments(self, run_gh):
+ issue = roadmap_sync.PlannedChange(
+ "PVTI_issue", "Issue", 42, "old", "old", False, "update", "", "body"
+ )
+ draft = roadmap_sync.PlannedChange(
+ "PVTI_draft", "DraftIssue", None, "old", "old", False, "update", "", "body"
+ )
+
+ roadmap_sync.apply_change(issue, "ATAC-Helicopter", "PVT_project")
+ roadmap_sync.apply_change(draft, "ATAC-Helicopter", "PVT_project")
+
+ self.assertEqual(2, run_gh.call_count)
+ self.assertIn("ATAC-Helicopter/VaultSync", run_gh.call_args_list[0].args[0])
+ self.assertIn("PVTI_draft", run_gh.call_args_list[1].args[0])
+
+ def test_apply_change_rejects_invalid_remote_identifiers(self):
+ invalid_issue = roadmap_sync.PlannedChange(
+ "", "Issue", -1, "", "", False, "update", "", "body"
+ )
+ invalid_draft = roadmap_sync.PlannedChange(
+ "--help", "DraftIssue", None, "", "", False, "update", "", "body"
+ )
+ with self.assertRaises(ValueError):
+ roadmap_sync.apply_change(invalid_issue, "ATAC-Helicopter", "PVT_project")
+ with self.assertRaises(ValueError):
+ roadmap_sync.apply_change(invalid_draft, "ATAC-Helicopter", "PVT_project")
+
def test_offline_dry_run_reports_exact_changes_without_writing(self):
completed = subprocess.run(
[
From bde641dfbbdc12bca56a46ee1bdee0dff2f41b3c Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:45:10 +0200
Subject: [PATCH 42/54] Keep roadmap content out of process arguments
---
scripts/roadmap_sync.py | 87 ++++++++++++-------
.../ReleaseManifestVerifierTests.cs | 3 +-
tests/scripts/test_roadmap_sync.py | 9 +-
3 files changed, 64 insertions(+), 35 deletions(-)
diff --git a/scripts/roadmap_sync.py b/scripts/roadmap_sync.py
index 6590efc2..c59340b7 100644
--- a/scripts/roadmap_sync.py
+++ b/scripts/roadmap_sync.py
@@ -13,8 +13,8 @@
from typing import Any, Iterable
-TICKET_ID_PATTERN = re.compile(r"(?:VS|ISS|BUG|REL)-[0-9]+")
-OWNER_PATTERN = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})")
+TICKET_ID_PATTERN = re.compile(r"(?:VS|ISS|BUG|REL)-\d+")
+OWNER_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,38}")
PROJECT_ID_PATTERN = re.compile(r"PVT[A-Za-z0-9_-]+")
ITEM_ID_PATTERN = re.compile(r"PVTI_[A-Za-z0-9_-]+")
MANAGED_BODY_PREFIX = "Synced from ROADMAP.md"
@@ -231,15 +231,18 @@ def _plan_item_change(
)
-def run_gh(arguments: list[str]) -> str:
+def run_gh(arguments: list[str], stdin_text: str | None = None) -> str:
executable = shutil.which("gh")
if executable is None:
raise RuntimeError("GitHub CLI (gh) is required")
+ # The executable is resolved locally, shell expansion is disabled, remote
+ # identifiers are validated, and document content travels only via stdin.
completed = subprocess.run(
- [executable, *arguments], # NOSONAR: fixed executable, no shell, validated identifiers.
+ [executable, *arguments], # NOSONAR
check=True,
shell=False,
text=True,
+ input=stdin_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
@@ -281,9 +284,9 @@ def load_items(args: argparse.Namespace) -> tuple[str, list[dict[str, Any]]]:
def validate_owner(owner: str) -> str:
- if OWNER_PATTERN.fullmatch(owner) is None:
- raise ValueError("--owner must be a valid GitHub account name")
- return owner
+ if OWNER_PATTERN.fullmatch(owner) is None or owner != "ATAC-Helicopter":
+ raise ValueError("--owner must identify the VaultSync repository owner")
+ return "ATAC-Helicopter"
def validate_project_number(project_number: int) -> str:
@@ -296,7 +299,7 @@ def resolve_input_path(raw_path: str) -> Path:
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = REPOSITORY_ROOT / candidate
- resolved = candidate.resolve(strict=True) # NOSONAR: containment is enforced before use.
+ resolved = candidate.resolve(strict=True)
if not resolved.is_relative_to(REPOSITORY_ROOT) or not resolved.is_file():
raise ValueError("Input files must be regular files inside the repository")
return resolved
@@ -304,34 +307,52 @@ def resolve_input_path(raw_path: str) -> Path:
def apply_change(change: PlannedChange, owner: str, project_id: str) -> None:
if change.content_type == "Issue" and change.issue_number:
- validated_owner = validate_owner(owner)
- if change.issue_number <= 0:
- raise ValueError("Issue number must be positive")
- arguments = [
- "issue",
- "edit",
- str(change.issue_number),
- "--repo",
- f"{validated_owner}/{REPOSITORY_NAME}",
- ]
- if change.title_changed:
- arguments.extend(["--title", change.new_title])
- if change.body_action == "update":
- arguments.extend(["--body", change.new_body])
- run_gh(arguments)
+ _apply_issue_change(change, owner)
return
if change.content_type == "DraftIssue" and change.item_id:
- if ITEM_ID_PATTERN.fullmatch(change.item_id) is None:
- raise ValueError("Draft item identifier is invalid")
- if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
- raise ValueError("Project identifier is invalid")
- arguments = ["project", "item-edit", "--id", change.item_id, "--project-id", project_id]
- if change.title_changed:
- arguments.extend(["--title", change.new_title])
- if change.body_action == "update":
- arguments.extend(["--body", change.new_body])
- run_gh(arguments)
+ _apply_draft_change(change, project_id)
+
+
+def _apply_issue_change(change: PlannedChange, owner: str) -> None:
+ validated_owner = validate_owner(owner)
+ if change.issue_number is None or change.issue_number <= 0:
+ raise ValueError("Issue number must be positive")
+ arguments = [
+ "issue",
+ "edit",
+ str(change.issue_number),
+ "--repo",
+ f"{validated_owner}/{REPOSITORY_NAME}",
+ "--body-file",
+ "-",
+ ]
+ run_gh(arguments, change.new_body)
+
+
+def _apply_draft_change(change: PlannedChange, project_id: str) -> None:
+ if ITEM_ID_PATTERN.fullmatch(change.item_id) is None:
+ raise ValueError("Draft item identifier is invalid")
+ if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
+ raise ValueError("Project identifier is invalid")
+ query = """mutation($projectId: ID!, $itemId: ID!, $body: String!) {
+ updateProjectV2DraftIssue(input: {
+ projectId: $projectId,
+ draftIssueId: $itemId,
+ body: $body
+ }) { projectV2DraftIssue { id } }
+}"""
+ request = json.dumps(
+ {
+ "query": query,
+ "variables": {
+ "projectId": project_id,
+ "itemId": change.item_id,
+ "body": change.new_body,
+ },
+ }
+ )
+ run_gh(["api", "graphql", "--input", "-"], request)
def parse_args() -> argparse.Namespace:
diff --git a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
index 8e1c41f0..8739bfbc 100644
--- a/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
+++ b/tests/VaultSync.Core.Tests/ReleaseManifestVerifierTests.cs
@@ -15,6 +15,7 @@ public sealed class ReleaseManifestVerifierTests
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";
+ private static readonly string[] s_linuxInstallerExtensions = [".deb", ".AppImage", ".tar.gz"];
[Fact]
public void ExactPublishedManifest_IsAccepted()
@@ -170,7 +171,7 @@ public void PlatformAssets_AreSelectedOnlyFromVerifiedManifestEntries()
Assert.EndsWith(".dmg", installer.InstallerName, StringComparison.OrdinalIgnoreCase);
else
Assert.Contains(
- new[] { ".deb", ".AppImage", ".tar.gz" },
+ s_linuxInstallerExtensions,
extension => installer.InstallerName!.EndsWith(extension, StringComparison.OrdinalIgnoreCase));
}
diff --git a/tests/scripts/test_roadmap_sync.py b/tests/scripts/test_roadmap_sync.py
index 88dd51e1..19dc8caa 100644
--- a/tests/scripts/test_roadmap_sync.py
+++ b/tests/scripts/test_roadmap_sync.py
@@ -112,6 +112,8 @@ def test_input_validation_rejects_unsafe_identifiers_and_external_paths(self):
self.assertEqual("7", roadmap_sync.validate_project_number(7))
with self.assertRaises(ValueError):
roadmap_sync.validate_owner("--help")
+ with self.assertRaises(ValueError):
+ roadmap_sync.validate_owner("different-owner")
with self.assertRaises(ValueError):
roadmap_sync.validate_project_number(0)
with tempfile.NamedTemporaryFile() as external:
@@ -164,7 +166,12 @@ def test_apply_change_uses_structured_issue_and_draft_arguments(self, run_gh):
self.assertEqual(2, run_gh.call_count)
self.assertIn("ATAC-Helicopter/VaultSync", run_gh.call_args_list[0].args[0])
- self.assertIn("PVTI_draft", run_gh.call_args_list[1].args[0])
+ self.assertNotIn("body", run_gh.call_args_list[0].args[0])
+ self.assertEqual("body", run_gh.call_args_list[0].args[1])
+ self.assertEqual(["api", "graphql", "--input", "-"], run_gh.call_args_list[1].args[0])
+ draft_request = json.loads(run_gh.call_args_list[1].args[1])
+ self.assertEqual("PVTI_draft", draft_request["variables"]["itemId"])
+ self.assertEqual("body", draft_request["variables"]["body"])
def test_apply_change_rejects_invalid_remote_identifiers(self):
invalid_issue = roadmap_sync.PlannedChange(
From e86f86f246a914016dc8c7f2ef2e8370ab144e7d Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:03:20 +0200
Subject: [PATCH 43/54] Bound disposable storage and guard managed mounts
---
CHANGELOG.md | 1 +
ROADMAP.md | 7 +
docs/RELEASE_1.8.7.md | 5 +
docs/STORAGE_HYGIENE.md | 46 ++++
src/VaultSync.Core/Services/BackupService.cs | 15 ++
.../Infrastructure/DiagnosticsLogger.cs | 4 +-
.../Infrastructure/StorageHygieneService.cs | 239 ++++++++++++++++++
src/VaultSync.UI/Program.cs | 8 +
.../ViewModels/SettingsViewModel.cs | 8 +
.../StorageHygieneTests.cs | 124 +++++++++
10 files changed, 455 insertions(+), 2 deletions(-)
create mode 100644 docs/STORAGE_HYGIENE.md
create mode 100644 src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
create mode 100644 tests/VaultSync.Core.Tests/StorageHygieneTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72accf67..5c3a937e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
- [BUG-18106] Normalized macOS SMB mount diagnostics to remove the complete credential-bearing share identity before masking any remaining raw or escaped password text.
- [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.
## [1.8.6] - 10.08.2026
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 6194e465..a8c0ecaf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -542,6 +542,13 @@ that exists only on the release branch or remains planned.
application restarts only when their release URL, exact size, and trusted
SHA-256 identity still match; tampered and linked entries fail closed.
- Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
+- [x] `BUG-18109` `P0` Bound disposable local storage and reject unbacked
+ macOS managed mount paths.
+ - Acceptance: startup cleanup applies tested age and size limits only to
+ re-creatable VaultSync data; databases, configuration, credentials,
+ backups, and mount contents remain outside cleanup; a managed mount path
+ cannot accept backup bytes unless SMB or NFS is currently mounted.
+ - Completed on the 1.8.7 release branch on 2026-08-15 in PR #546.
### Delivery sequence
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index e7ec1380..d7315ceb 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -73,6 +73,11 @@ assumed and must be approved explicitly if the release needs one.
- Canonical and platform patch manifests now use an immutable, digest-verified
on-disk cache, preventing routine update checks and application restarts from
inflating GitHub JSON asset download counts (`BUG-18108`).
+- Disposable local storage now has explicit age and size limits for diagnostics,
+ logs, caches, updater artifacts, and abandoned temporary work. Managed macOS
+ mount paths also fail closed unless SMB or NFS is still mounted, preventing
+ remote backup payloads from falling through onto the system drive
+ (`BUG-18109`). See [Local storage and cleanup](STORAGE_HYGIENE.md).
- Roadmap-to-GitHub synchronization now reconstructs wrapped ticket contracts,
preserves manually maintained issue bodies, constrains file inputs to the
repository, validates every remote identifier, and provides an exact
diff --git a/docs/STORAGE_HYGIENE.md b/docs/STORAGE_HYGIENE.md
new file mode 100644
index 00000000..8de77f1d
--- /dev/null
+++ b/docs/STORAGE_HYGIENE.md
@@ -0,0 +1,46 @@
+# Local storage and cleanup
+
+VaultSync separates durable user state from disposable working data. Automatic
+cleanup is intentionally allowed to remove only data that the application can
+recreate. It never treats a backup destination, database, configuration file,
+credential store, or managed mount contents as cache.
+
+## Storage map
+
+| Location | Contents | Policy |
+| --- | --- | --- |
+| User configuration directory (`~/.vaultsync` by default) | Settings, optional legacy database/state, telemetry identity, and legacy command logs | Settings and identity are durable. Logs older than 14 days are removed and retained logs are capped at 10 MB. Abandoned configuration writes older than one hour are removed. |
+| Per-user application-data `VaultSync` directory | Main database, credentials, installation identity, UI preferences, diagnostics, caches, updater working data, and macOS managed mount points | Durable state is retained. Disposable subdirectories follow the limits below. The `mounts` subtree is never automatically deleted. |
+| `diagnostics` | Session logs, samples, traces, and optional hang dumps | Pruned at startup and every six hours. Five recent text artifacts are retained per type, at most one hang dump is retained, and combined diagnostic evidence is capped at 128 MB. |
+| `cache/scan` | Re-creatable directory scan acceleration | Files older than 30 days are removed; retained entries are capped at 20 MB. |
+| `cache/release-assets` | Digest-verified release manifests | Files older than 180 days are removed; retained entries are capped at 10 MB. |
+| `patches` | Downloaded patch archives | Verified archives older than one day are removed. Incomplete downloads are already removed when a download finishes or fails. |
+| `patch-runtime` | Temporary copied updater helper and extraction directories | Helper copies older than one day and staging directories older than one hour are removed. The helper log is limited to 1 MB and 14 days. |
+| OS temporary directory | Decrypted-open workspaces, restores, key rotation, archive uploads, updater downloads, recovery tests, and tool exclude files | Each operation cleans its own working data. Startup also removes abandoned VaultSync working data older than one day. Decrypted-open workspaces have a shorter in-app lock timeout. |
+| User-selected backup destinations | Backup payloads and portable metadata | Governed only by configured backup retention and protection rules; never by cache cleanup. |
+
+## Managed network mounts on macOS
+
+`~/Library/Application Support/VaultSync/mounts` contains mount points, not a
+fallback backup destination. Before creating a backup folder, VaultSync now
+requires a managed path to be backed by a currently mounted SMB or NFS
+filesystem. A directory that merely still exists after a share disconnects is
+rejected. This prevents remote backup bytes from silently consuming the local
+system drive.
+
+Existing files found beneath an unmounted managed path are not deleted
+automatically: they may be the only copy of a backup produced during a previous
+disconnect. The user should inspect and move or remove them only after verifying
+that the intended remote destination contains the same restore point.
+
+## Design rules
+
+- Cleanup runs best-effort and can never block application startup.
+- Recent or active working files remain available long enough for retries.
+- Cleanup follows exact VaultSync-owned names beneath resolved per-user roots.
+- Symbolic-link directories are removed only as links and are never traversed.
+- Size caps discard the oldest disposable files first.
+- The confirmed **Clear local cache** action removes the same cache, patch,
+ legacy-log, crash-report, and temporary-data families immediately, while
+ continuing to exclude databases, configuration, credentials, backups, and
+ managed mount contents.
diff --git a/src/VaultSync.Core/Services/BackupService.cs b/src/VaultSync.Core/Services/BackupService.cs
index df08f126..fc2d3ffb 100644
--- a/src/VaultSync.Core/Services/BackupService.cs
+++ b/src/VaultSync.Core/Services/BackupService.cs
@@ -855,6 +855,15 @@ public async Task RunBackupAsync(
// Normalise backup root and ensure it exists (e.g. mounted NAS/share).
backupRoot = Path.GetFullPath(backupRoot);
+ if (ShouldRejectUnbackedManagedMount(
+ OperatingSystem.IsMacOS(),
+ IsMacManagedMountPath(backupRoot),
+ IsNetworkMountPath(backupRoot)))
+ {
+ throw new InvalidOperationException(
+ $"Backup root '{backupRoot}' is a VaultSync-managed network mount point, but its share is not mounted. " +
+ "The backup was stopped to avoid writing remote backup data onto the local system drive.");
+ }
if (!Directory.Exists(backupRoot))
{
throw new InvalidOperationException(
@@ -3624,6 +3633,12 @@ private static bool IsNfsMountPath(string path)
private static bool IsNetworkMountPath(string path)
=> IsSmbfsMountPath(path) || IsNfsMountPath(path);
+ internal static bool ShouldRejectUnbackedManagedMount(
+ bool isMacOs,
+ bool isManagedMountPath,
+ bool isNetworkMountPath) =>
+ isMacOs && isManagedMountPath && !isNetworkMountPath;
+
private static bool IsOnPath(string tool)
{
string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
diff --git a/src/VaultSync.UI/Infrastructure/DiagnosticsLogger.cs b/src/VaultSync.UI/Infrastructure/DiagnosticsLogger.cs
index 0fa23f48..d4d2d875 100644
--- a/src/VaultSync.UI/Infrastructure/DiagnosticsLogger.cs
+++ b/src/VaultSync.UI/Infrastructure/DiagnosticsLogger.cs
@@ -15,8 +15,8 @@ internal static class DiagnosticsLogger
private const int MaxRecent = 1000;
private const int MaxFirstChanceTotal = 250;
private const int MaxFirstChancePerSignature = 5;
- private const int MaxHangDumpFiles = 2;
- private const long MaxDiagnosticsBytes = 1024L * 1024L * 1024L;
+ private const int MaxHangDumpFiles = 1;
+ private const long MaxDiagnosticsBytes = 128L * 1024L * 1024L;
private const string ProductDirectoryName = "VaultSync";
private const string DiagnosticsDirectoryName = "diagnostics";
private static readonly TimeSpan DiagnosticsPruneInterval = TimeSpan.FromHours(6);
diff --git a/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs b/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
new file mode 100644
index 00000000..9dd6bcb4
--- /dev/null
+++ b/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace VaultSync.UI.Infrastructure;
+
+internal readonly record struct StorageCleanupSummary(int FilesRemoved, int DirectoriesRemoved, long BytesReclaimed)
+{
+ public StorageCleanupSummary Add(StorageCleanupSummary other) => new(
+ FilesRemoved + other.FilesRemoved,
+ DirectoriesRemoved + other.DirectoriesRemoved,
+ BytesReclaimed + other.BytesReclaimed);
+}
+
+///
+/// Removes disposable VaultSync artifacts that are safe to recreate. Backup
+/// destinations, databases, configuration, credentials, and mount contents are
+/// deliberately outside this service's scope.
+///
+internal static class StorageHygieneService
+{
+ private static readonly TimeSpan PatchRetention = TimeSpan.FromDays(1);
+ private static readonly TimeSpan PatchWorkingRetention = TimeSpan.FromHours(1);
+ private static readonly TimeSpan LogRetention = TimeSpan.FromDays(14);
+ private static readonly TimeSpan ScanCacheRetention = TimeSpan.FromDays(30);
+ private static readonly TimeSpan ReleaseMetadataRetention = TimeSpan.FromDays(180);
+ private static readonly TimeSpan TemporaryRetention = TimeSpan.FromDays(1);
+ private const long MaximumLegacyLogBytes = 10L * 1024L * 1024L;
+ private static readonly string[] TemporaryDirectoryPatterns =
+ [
+ "vaultsync-open-*",
+ "vaultsync-rotate-*",
+ "vaultsync-restore-*",
+ "vaultsync_archive_*"
+ ];
+
+ internal static StorageCleanupSummary RunStartupCleanup(DateTime? utcNow = null)
+ {
+ DateTime now = utcNow ?? DateTime.UtcNow;
+ StorageCleanupSummary summary = default;
+
+ string localData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (!string.IsNullOrWhiteSpace(localData))
+ {
+ summary = summary.Add(PruneApplicationData(
+ Path.Combine(localData, "VaultSync"),
+ now));
+ }
+
+ string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrWhiteSpace(userProfile))
+ {
+ summary = summary.Add(PruneLegacyData(
+ Path.Combine(userProfile, ".vaultsync"),
+ now));
+ }
+
+ summary = summary.Add(PruneTemporaryData(Path.GetTempPath(), now));
+ return summary;
+ }
+
+ internal static StorageCleanupSummary PruneApplicationData(string root, DateTime utcNow)
+ {
+ StorageCleanupSummary summary = default;
+ summary = summary.Add(PruneFiles(
+ Path.Combine(root, "patches"),
+ static _ => true,
+ utcNow - PatchRetention));
+
+ string patchRuntime = Path.Combine(root, "patch-runtime");
+ summary = summary.Add(PruneDirectories(
+ patchRuntime,
+ "patch-helper-*",
+ utcNow - PatchRetention));
+ summary = summary.Add(PruneDirectories(
+ patchRuntime,
+ "patch-*",
+ utcNow - PatchWorkingRetention,
+ directory => Guid.TryParseExact(
+ directory.Name["patch-".Length..],
+ "N",
+ out _)));
+ summary = summary.Add(PruneFiles(
+ patchRuntime,
+ file => string.Equals(file.Name, "patch-helper.log", StringComparison.OrdinalIgnoreCase),
+ utcNow - LogRetention,
+ maximumRetainedBytes: 1024L * 1024L));
+ summary = summary.Add(PruneFiles(
+ Path.Combine(root, "cache", "scan"),
+ file => file.Extension.Equals(".json", StringComparison.OrdinalIgnoreCase),
+ utcNow - ScanCacheRetention,
+ maximumRetainedBytes: 20L * 1024L * 1024L));
+ summary = summary.Add(PruneFiles(
+ Path.Combine(root, "cache", "release-assets"),
+ file => file.Extension.Equals(".json", StringComparison.OrdinalIgnoreCase),
+ utcNow - ReleaseMetadataRetention,
+ maximumRetainedBytes: 10L * 1024L * 1024L));
+ return summary;
+ }
+
+ internal static StorageCleanupSummary PruneLegacyData(string root, DateTime utcNow)
+ {
+ StorageCleanupSummary summary = PruneFiles(
+ Path.Combine(root, "logs"),
+ file => file.Extension.Equals(".log", StringComparison.OrdinalIgnoreCase),
+ utcNow - LogRetention,
+ MaximumLegacyLogBytes);
+ return summary.Add(PruneFiles(
+ root,
+ file => file.Name.StartsWith("appsettings.tmp.", StringComparison.OrdinalIgnoreCase) &&
+ file.Extension.Equals(".json", StringComparison.OrdinalIgnoreCase),
+ utcNow - PatchWorkingRetention));
+ }
+
+ internal static StorageCleanupSummary PruneTemporaryData(string tempRoot, DateTime utcNow)
+ {
+ StorageCleanupSummary summary = default;
+ foreach (string pattern in TemporaryDirectoryPatterns)
+ {
+ summary = summary.Add(PruneDirectories(tempRoot, pattern, utcNow - TemporaryRetention));
+ }
+
+ summary = summary.Add(PruneFiles(
+ tempRoot,
+ file => file.Name.StartsWith("vaultsync_exclude_", StringComparison.OrdinalIgnoreCase) &&
+ file.Extension.Equals(".txt", StringComparison.OrdinalIgnoreCase),
+ utcNow - TemporaryRetention));
+ summary = summary.Add(PruneDirectories(
+ Path.Combine(tempRoot, "VaultSync"),
+ "updates",
+ utcNow - TemporaryRetention));
+ summary = summary.Add(PruneDirectories(
+ Path.Combine(tempRoot, "VaultSync"),
+ "recovery-tests",
+ utcNow - TemporaryRetention));
+ return summary;
+ }
+
+ private static StorageCleanupSummary PruneFiles(
+ string directory,
+ Func include,
+ DateTime cutoffUtc,
+ long maximumRetainedBytes = long.MaxValue)
+ {
+ try
+ {
+ if (!Directory.Exists(directory))
+ return default;
+
+ FileInfo[] files = new DirectoryInfo(directory)
+ .EnumerateFiles("*", SearchOption.TopDirectoryOnly)
+ .Where(include)
+ .OrderByDescending(file => file.LastWriteTimeUtc)
+ .ToArray();
+ long retainedBytes = 0;
+ StorageCleanupSummary summary = default;
+ foreach (FileInfo file in files)
+ {
+ bool expired = file.LastWriteTimeUtc < cutoffUtc;
+ bool exceedsCap = file.Length > maximumRetainedBytes - retainedBytes;
+ if (!expired && !exceedsCap)
+ {
+ retainedBytes += file.Length;
+ continue;
+ }
+
+ long length = file.Length;
+ try
+ {
+ file.Delete();
+ summary = summary.Add(new StorageCleanupSummary(1, 0, length));
+ }
+ catch
+ {
+ // Cleanup is best effort and must never block application startup.
+ }
+ }
+ return summary;
+ }
+ catch
+ {
+ return default;
+ }
+ }
+
+ private static StorageCleanupSummary PruneDirectories(
+ string root,
+ string pattern,
+ DateTime cutoffUtc,
+ Func? include = null)
+ {
+ try
+ {
+ if (!Directory.Exists(root))
+ return default;
+
+ StorageCleanupSummary summary = default;
+ foreach (DirectoryInfo directory in new DirectoryInfo(root)
+ .EnumerateDirectories(pattern, SearchOption.TopDirectoryOnly)
+ .Where(candidate => include?.Invoke(candidate) ?? true))
+ {
+ if (directory.LastWriteTimeUtc >= cutoffUtc)
+ continue;
+
+ long bytes = TryGetDirectorySize(directory);
+ try
+ {
+ bool isLink = (directory.Attributes & FileAttributes.ReparsePoint) != 0;
+ directory.Delete(recursive: !isLink);
+ summary = summary.Add(new StorageCleanupSummary(0, 1, bytes));
+ }
+ catch
+ {
+ // Cleanup is best effort and must never block application startup.
+ }
+ }
+ return summary;
+ }
+ catch
+ {
+ return default;
+ }
+ }
+
+ private static long TryGetDirectorySize(DirectoryInfo directory)
+ {
+ try
+ {
+ if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
+ return 0;
+ return directory.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+}
diff --git a/src/VaultSync.UI/Program.cs b/src/VaultSync.UI/Program.cs
index cb302345..2c60d928 100644
--- a/src/VaultSync.UI/Program.cs
+++ b/src/VaultSync.UI/Program.cs
@@ -64,6 +64,14 @@ public static void Main(string[] args)
return;
}
+ StorageCleanupSummary cleanup = StorageHygieneService.RunStartupCleanup();
+ if (cleanup.FilesRemoved > 0 || cleanup.DirectoriesRemoved > 0)
+ {
+ DiagnosticsLogger.Record(
+ $"Storage hygiene reclaimed {cleanup.BytesReclaimed} bytes from " +
+ $"{cleanup.FilesRemoved} file(s) and {cleanup.DirectoriesRemoved} directory/directories.");
+ }
+
try
{
_activationListenerCts = new CancellationTokenSource();
diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
index 020266db..6820c5bd 100644
--- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
+++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
@@ -3103,9 +3103,17 @@ void Count(CacheDeleteResult result)
Count(TryDeleteDir(Path.Combine(localRoot, "logs")));
Count(TryDeleteDir(Path.Combine(localRoot, "crash")));
+ Count(TryDeleteDir(Path.Combine(localRoot, "cache")));
+ Count(TryDeleteDir(Path.Combine(localRoot, "patches")));
+ Count(TryDeleteDir(Path.Combine(localRoot, "patch-runtime")));
Count(TryDeleteFile(Path.Combine(localRoot, "avatars.json")));
Count(TryDeleteFile(Path.Combine(localRoot, "avatar-colors.json")));
+ string userRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".vaultsync");
+ Count(TryDeleteDir(Path.Combine(userRoot, "logs")));
+
string tempRoot = Path.GetTempPath();
Count(TryDeleteDir(Path.Combine(tempRoot, "vaultsync-meta-import")));
Count(TryDeleteDir(Path.Combine(tempRoot, "vaultsync-telemetry-export")));
diff --git a/tests/VaultSync.Core.Tests/StorageHygieneTests.cs b/tests/VaultSync.Core.Tests/StorageHygieneTests.cs
new file mode 100644
index 00000000..9ee15b0f
--- /dev/null
+++ b/tests/VaultSync.Core.Tests/StorageHygieneTests.cs
@@ -0,0 +1,124 @@
+using System;
+using System.IO;
+using VaultSync.Core.Services;
+using VaultSync.Core.Tests.TestSupport;
+using VaultSync.UI.Infrastructure;
+using Xunit;
+
+namespace VaultSync.Core.Tests;
+
+public sealed class StorageHygieneTests
+{
+ [Fact]
+ public void ApplicationCleanupRemovesExpiredPatchArtifactsButNeverOtherData()
+ {
+ using var root = new TempDirectory();
+ DateTime now = DateTime.UtcNow;
+ string oldArchive = CreateFile(root.Path, "patches/old.zip", 32, now.AddDays(-2));
+ string recentArchive = CreateFile(root.Path, "patches/recent.zip", 16, now.AddHours(-2));
+ string oldHelper = CreateDirectory(root.Path, "patch-runtime/patch-helper-old", 64, now.AddDays(-2));
+ string recentHelper = CreateDirectory(root.Path, "patch-runtime/patch-helper-recent", 8, now.AddHours(-2));
+ string oldStaging = CreateDirectory(
+ root.Path,
+ $"patch-runtime/patch-{Guid.NewGuid():N}",
+ 24,
+ now.AddHours(-2));
+ string scanCache = CreateFile(root.Path, "cache/scan/1.json", 6, now.AddDays(-31));
+ string mountPayload = CreateFile(root.Path, "mounts/share/project/backup.zip", 128, now.AddYears(-1));
+
+ StorageCleanupSummary result = StorageHygieneService.PruneApplicationData(root.Path, now);
+
+ Assert.False(File.Exists(oldArchive));
+ Assert.True(File.Exists(recentArchive));
+ Assert.False(Directory.Exists(oldHelper));
+ Assert.True(Directory.Exists(recentHelper));
+ Assert.False(Directory.Exists(oldStaging));
+ Assert.False(File.Exists(scanCache));
+ Assert.True(File.Exists(mountPayload));
+ Assert.Equal(2, result.FilesRemoved);
+ Assert.Equal(2, result.DirectoriesRemoved);
+ Assert.Equal(126, result.BytesReclaimed);
+ }
+
+ [Fact]
+ public void LegacyCleanupPrunesOldLogsAndAbandonedConfigWrites()
+ {
+ using var root = new TempDirectory();
+ DateTime now = DateTime.UtcNow;
+ string oldLog = CreateFile(root.Path, "logs/old.log", 10, now.AddDays(-20));
+ string recentLog = CreateFile(root.Path, "logs/recent.log", 10, now.AddHours(-1));
+ string staleWrite = CreateFile(root.Path, "appsettings.tmp.deadbeef.json", 5, now.AddHours(-2));
+ string config = CreateFile(root.Path, "appsettings.json", 20, now.AddYears(-1));
+
+ StorageCleanupSummary result = StorageHygieneService.PruneLegacyData(root.Path, now);
+
+ Assert.False(File.Exists(oldLog));
+ Assert.True(File.Exists(recentLog));
+ Assert.False(File.Exists(staleWrite));
+ Assert.True(File.Exists(config));
+ Assert.Equal(2, result.FilesRemoved);
+ Assert.Equal(15, result.BytesReclaimed);
+ }
+
+ [Fact]
+ public void TemporaryCleanupRemovesOnlyExpiredVaultSyncWorkingData()
+ {
+ using var root = new TempDirectory();
+ DateTime now = DateTime.UtcNow;
+ string oldOpen = CreateDirectory(root.Path, "vaultsync-open-old", 12, now.AddDays(-2));
+ string recentOpen = CreateDirectory(root.Path, "vaultsync-open-recent", 12, now.AddHours(-1));
+ string unrelated = CreateDirectory(root.Path, "another-app", 12, now.AddYears(-1));
+ string oldExclude = CreateFile(root.Path, "vaultsync_exclude_old.txt", 4, now.AddDays(-2));
+
+ StorageCleanupSummary result = StorageHygieneService.PruneTemporaryData(root.Path, now);
+
+ Assert.False(Directory.Exists(oldOpen));
+ Assert.True(Directory.Exists(recentOpen));
+ Assert.True(Directory.Exists(unrelated));
+ Assert.False(File.Exists(oldExclude));
+ Assert.Equal(1, result.FilesRemoved);
+ Assert.Equal(1, result.DirectoriesRemoved);
+ }
+
+ [Theory]
+ [InlineData(true, true, false, true)]
+ [InlineData(true, true, true, false)]
+ [InlineData(true, false, false, false)]
+ [InlineData(false, true, false, false)]
+ public void ManagedMountGuardRejectsOnlyUnbackedMacMounts(
+ bool isMacOs,
+ bool isManaged,
+ bool isNetworkMount,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ BackupService.ShouldRejectUnbackedManagedMount(isMacOs, isManaged, isNetworkMount));
+ }
+
+ private static string CreateDirectory(
+ string root,
+ string relativePath,
+ int payloadBytes,
+ DateTime lastWriteUtc)
+ {
+ string directory = Path.Combine(root, relativePath);
+ Directory.CreateDirectory(directory);
+ File.WriteAllBytes(Path.Combine(directory, "payload.bin"), new byte[payloadBytes]);
+ Directory.SetLastWriteTimeUtc(directory, lastWriteUtc);
+ return directory;
+ }
+
+ private static string CreateFile(
+ string root,
+ string relativePath,
+ int length,
+ DateTime lastWriteUtc)
+ {
+ string path = Path.Combine(root, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, new byte[length]);
+ File.SetLastWriteTimeUtc(path, lastWriteUtc);
+ return path;
+ }
+}
From 911db425b37e34afad7908adf82e0babbab24f2f Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:05:05 +0200
Subject: [PATCH 44/54] Prune stale updater and recovery work individually
---
.../Infrastructure/StorageHygieneService.cs | 10 +++++-----
tests/VaultSync.Core.Tests/StorageHygieneTests.cs | 10 ++++++++--
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs b/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
index 9dd6bcb4..329c0bc6 100644
--- a/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
+++ b/src/VaultSync.UI/Infrastructure/StorageHygieneService.cs
@@ -126,13 +126,13 @@ internal static StorageCleanupSummary PruneTemporaryData(string tempRoot, DateTi
file => file.Name.StartsWith("vaultsync_exclude_", StringComparison.OrdinalIgnoreCase) &&
file.Extension.Equals(".txt", StringComparison.OrdinalIgnoreCase),
utcNow - TemporaryRetention));
- summary = summary.Add(PruneDirectories(
- Path.Combine(tempRoot, "VaultSync"),
- "updates",
+ summary = summary.Add(PruneFiles(
+ Path.Combine(tempRoot, "VaultSync", "updates"),
+ static _ => true,
utcNow - TemporaryRetention));
summary = summary.Add(PruneDirectories(
- Path.Combine(tempRoot, "VaultSync"),
- "recovery-tests",
+ Path.Combine(tempRoot, "VaultSync", "recovery-tests"),
+ "*",
utcNow - TemporaryRetention));
return summary;
}
diff --git a/tests/VaultSync.Core.Tests/StorageHygieneTests.cs b/tests/VaultSync.Core.Tests/StorageHygieneTests.cs
index 9ee15b0f..9386646b 100644
--- a/tests/VaultSync.Core.Tests/StorageHygieneTests.cs
+++ b/tests/VaultSync.Core.Tests/StorageHygieneTests.cs
@@ -69,6 +69,9 @@ public void TemporaryCleanupRemovesOnlyExpiredVaultSyncWorkingData()
string recentOpen = CreateDirectory(root.Path, "vaultsync-open-recent", 12, now.AddHours(-1));
string unrelated = CreateDirectory(root.Path, "another-app", 12, now.AddYears(-1));
string oldExclude = CreateFile(root.Path, "vaultsync_exclude_old.txt", 4, now.AddDays(-2));
+ string oldInstaller = CreateFile(root.Path, "VaultSync/updates/old-installer", 10, now.AddDays(-2));
+ string recentInstaller = CreateFile(root.Path, "VaultSync/updates/recent-installer", 10, now.AddHours(-2));
+ string oldRecovery = CreateDirectory(root.Path, "VaultSync/recovery-tests/old", 14, now.AddDays(-2));
StorageCleanupSummary result = StorageHygieneService.PruneTemporaryData(root.Path, now);
@@ -76,8 +79,11 @@ public void TemporaryCleanupRemovesOnlyExpiredVaultSyncWorkingData()
Assert.True(Directory.Exists(recentOpen));
Assert.True(Directory.Exists(unrelated));
Assert.False(File.Exists(oldExclude));
- Assert.Equal(1, result.FilesRemoved);
- Assert.Equal(1, result.DirectoriesRemoved);
+ Assert.False(File.Exists(oldInstaller));
+ Assert.True(File.Exists(recentInstaller));
+ Assert.False(Directory.Exists(oldRecovery));
+ Assert.Equal(2, result.FilesRemoved);
+ Assert.Equal(2, result.DirectoriesRemoved);
}
[Theory]
From 2acb090317794d9fe92f7b4e49618d487a1c2456 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Sun, 16 Aug 2026 02:53:47 +0200
Subject: [PATCH 45/54] Expose repository writer safety controls
---
CHANGELOG.md | 1 +
ROADMAP.md | 4 +-
docs/CROSS_MACHINE_SAFETY.md | 9 +
docs/RELEASE_1.8.7.md | 20 +-
.../ViewModels/AppViewModel.StartupOps.cs | 7 +-
.../ViewModels/BackupDestinationViewModel.cs | 38 +++
.../SettingsViewModel.RepositoryWriter.cs | 256 ++++++++++++++++++
.../ViewModels/SettingsViewModel.cs | 56 ++--
src/VaultSync.UI/Views/SettingsView.axaml | 84 +++++-
.../RepositoryLeaseServiceTests.cs | 31 +++
.../RepositoryWriterUiTests.cs | 125 +++++++++
11 files changed, 603 insertions(+), 28 deletions(-)
create mode 100644 src/VaultSync.UI/ViewModels/SettingsViewModel.RepositoryWriter.cs
create mode 100644 tests/VaultSync.Core.Tests/RepositoryWriterUiTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c3a937e..47ed0bdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
- [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.
diff --git a/ROADMAP.md b/ROADMAP.md
index a8c0ecaf..6a721b44 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -433,7 +433,7 @@ that exists only on the release branch or remains planned.
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,
+- [x] `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;
@@ -442,6 +442,8 @@ that exists only on the release branch or remains planned.
- 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.
+ - Completed on the 1.8.7 release branch on 2026-08-16 in PR #546, including
+ per-destination owner inspection and explicit nonce-bound stale takeover.
- [ ] `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
diff --git a/docs/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md
index ed50aa8e..e4c87816 100644
--- a/docs/CROSS_MACHINE_SAFETY.md
+++ b/docs/CROSS_MACHINE_SAFETY.md
@@ -98,6 +98,13 @@ 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.
+Settings exposes that decision per destination. Inspection is read-only and
+shows the diagnostic host label, a short durable-identity prefix, operation,
+application version, heartbeat, and expiry. Takeover is offered only for a stale
+lease, requires a separate confirmation, remounts and rechecks the same resolved
+repository and nonce, and records the displaced lease before clearing it. The
+interface also states that clients older than 1.8.7 do not honor this protocol.
+
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.
@@ -143,6 +150,8 @@ revision.
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.
+ **Writer status and explicit stale takeover were implemented on 2026-08-16;
+ versioned conflict review remains part of steps 4–6.**
8. Qualify local disk, SMB/NAS, disconnection, skew, crash, and mixed-version
scenarios before enabling multi-machine writes by default.
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index d7315ceb..3f6b0ea5 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-15
+## Status as of 2026-08-16
### Implemented on the release branch
@@ -41,13 +41,18 @@ assumed and must be approved explicitly if the release needs one.
- 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.
+ stale takeover, and exceptional takeover evidence. Settings can inspect each
+ destination's current writer, show its host, short durable identity,
+ operation, version, heartbeat, and expiry, and require an explicit two-step
+ confirmation before replacing the exact inspected stale nonce. The displaced
+ lease is retained as evidence, and the UI warns that pre-1.8.7 clients do not
+ participate in this protocol.
- 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.
+ instead of being overwritten.
- 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
@@ -90,13 +95,12 @@ branch package.
### In progress next
-1. Surface repository status and an explicit stale-takeover decision in UI.
-2. Make metadata imports previewable, versioned, durable, and reversible.
-3. Expose the running build and canonical release identity through the app,
+1. Make metadata imports previewable, versioned, durable, and reversible.
+2. 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
+3. 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,
+4. Reduce codebase duplication and oversized orchestration through shared,
regression-tested primitives without combining genuinely different platform
behavior.
diff --git a/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs b/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs
index 706c9aab..acf0702a 100644
--- a/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs
+++ b/src/VaultSync.UI/ViewModels/AppViewModel.StartupOps.cs
@@ -74,7 +74,12 @@ internal AppViewModel(IAppConfigStore configStore, IRepositoryFactory? repositor
_projectsViewModel.AutoBackupGroupPreferenceChanged += OnAutoBackupGroupPreferenceChanged;
_projectsViewModel.ProjectRemovedFromDatabase += OnProjectRemovedFromDatabase;
_backupsViewModel = null;
- _settingsViewModel = new SettingsViewModel(_localizationService, _configStore, _repositoryFactory);
+ _settingsViewModel = new SettingsViewModel(
+ _localizationService,
+ _configStore,
+ _repositoryFactory,
+ installationIdentityProvider: _installationIdentityProvider,
+ appVersion: _currentVersionString);
_scheduleViewModel = new ScheduleViewModel(
_settingsViewModel,
_localizationService,
diff --git a/src/VaultSync.UI/ViewModels/BackupDestinationViewModel.cs b/src/VaultSync.UI/ViewModels/BackupDestinationViewModel.cs
index cfe22694..098d4481 100644
--- a/src/VaultSync.UI/ViewModels/BackupDestinationViewModel.cs
+++ b/src/VaultSync.UI/ViewModels/BackupDestinationViewModel.cs
@@ -173,5 +173,43 @@ public string LastTestSeverity
set => SetField(ref _lastTestSeverity, value);
}
+ private string _repositoryWriterStatus = "Not checked";
+ public string RepositoryWriterStatus
+ {
+ get => _repositoryWriterStatus;
+ set => SetField(ref _repositoryWriterStatus, value);
+ }
+
+ private string _repositoryWriterDetails = "Check before using this destination from more than one machine.";
+ public string RepositoryWriterDetails
+ {
+ get => _repositoryWriterDetails;
+ set => SetField(ref _repositoryWriterDetails, value);
+ }
+
+ private bool _isRepositoryWriterBusy;
+ public bool IsRepositoryWriterBusy
+ {
+ get => _isRepositoryWriterBusy;
+ set => SetField(ref _isRepositoryWriterBusy, value);
+ }
+
+ private bool _canReviewStaleWriter;
+ public bool CanReviewStaleWriter
+ {
+ get => _canReviewStaleWriter;
+ set => SetField(ref _canReviewStaleWriter, value);
+ }
+
+ private bool _isWriterTakeoverConfirmationVisible;
+ public bool IsWriterTakeoverConfirmationVisible
+ {
+ get => _isWriterTakeoverConfirmationVisible;
+ set => SetField(ref _isWriterTakeoverConfirmationVisible, value);
+ }
+
+ internal string InspectedRepositoryRoot { get; set; } = string.Empty;
+ internal string StaleWriterNonce { get; set; } = string.Empty;
+
public string DisplayName => string.IsNullOrWhiteSpace(Alias) ? Path : Alias;
}
diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.RepositoryWriter.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.RepositoryWriter.cs
new file mode 100644
index 00000000..69d919cc
--- /dev/null
+++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.RepositoryWriter.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using VaultSync.Core.Config;
+using VaultSync.Core.Models;
+using VaultSync.Core.Services;
+using VaultSync.UI.Infrastructure;
+
+namespace VaultSync.UI;
+
+public sealed partial class SettingsViewModel
+{
+ private void InspectRepositoryWriter(BackupDestinationViewModel? destination)
+ {
+ _ = DetachedTask.RunAsync(
+ () => InspectRepositoryWriterAsync(destination),
+ nameof(InspectRepositoryWriterAsync));
+ }
+
+ internal async Task InspectRepositoryWriterAsync(BackupDestinationViewModel? destination)
+ {
+ if (destination is null || string.IsNullOrWhiteSpace(destination.Path))
+ return;
+
+ destination.IsRepositoryWriterBusy = true;
+ destination.IsWriterTakeoverConfirmationVisible = false;
+ try
+ {
+ AppConfig config = await Task.Run(_configStore.Load);
+ NetworkCredentialProfile? profile = ResolveCredential(config, destination.CredentialName);
+ BackupDestination model = BuildDestinationModel(destination);
+ DestinationResolution resolution = await Task.Run(() => _networkMountService.PrepareDestination(model, profile));
+ if (!resolution.IsSuccess || string.IsNullOrWhiteSpace(resolution.EffectivePath))
+ {
+ ApplyRepositoryWriterInspection(
+ destination,
+ new RepositoryLeaseInspection(
+ RepositoryLeaseState.Unavailable,
+ null,
+ resolution.Message));
+ return;
+ }
+
+ try
+ {
+ RepositoryLeaseInspection inspection = await Task.Run(
+ () => _repositoryLeaseService.Inspect(resolution.EffectivePath));
+ ApplyRepositoryWriterInspection(destination, inspection, resolution.EffectivePath);
+ }
+ finally
+ {
+ NetworkMountService.Cleanup(resolution);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
+ {
+ ApplyRepositoryWriterInspection(
+ destination,
+ new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, ex.Message));
+ }
+ finally
+ {
+ destination.IsRepositoryWriterBusy = false;
+ }
+ }
+
+ private static void ReviewStaleWriter(BackupDestinationViewModel? destination)
+ {
+ if (destination?.CanReviewStaleWriter == true)
+ destination.IsWriterTakeoverConfirmationVisible = true;
+ }
+
+ private static void CancelStaleWriterTakeover(BackupDestinationViewModel? destination)
+ {
+ if (destination is not null)
+ destination.IsWriterTakeoverConfirmationVisible = false;
+ }
+
+ private void ConfirmStaleWriterTakeover(BackupDestinationViewModel? destination)
+ {
+ _ = DetachedTask.RunAsync(
+ () => ConfirmStaleWriterTakeoverAsync(destination),
+ nameof(ConfirmStaleWriterTakeoverAsync));
+ }
+
+ internal async Task ConfirmStaleWriterTakeoverAsync(BackupDestinationViewModel? destination)
+ {
+ if (destination is null ||
+ !destination.CanReviewStaleWriter ||
+ string.IsNullOrWhiteSpace(destination.StaleWriterNonce) ||
+ string.IsNullOrWhiteSpace(destination.InspectedRepositoryRoot))
+ {
+ return;
+ }
+
+ string expectedNonce = destination.StaleWriterNonce;
+ string expectedRoot = destination.InspectedRepositoryRoot;
+ destination.IsRepositoryWriterBusy = true;
+ destination.IsWriterTakeoverConfirmationVisible = false;
+
+ try
+ {
+ AppConfig config = await Task.Run(_configStore.Load);
+ NetworkCredentialProfile? profile = ResolveCredential(config, destination.CredentialName);
+ BackupDestination model = BuildDestinationModel(destination);
+ DestinationResolution resolution = await Task.Run(() => _networkMountService.PrepareDestination(model, profile));
+ if (!resolution.IsSuccess || string.IsNullOrWhiteSpace(resolution.EffectivePath))
+ {
+ ApplyRepositoryWriterInspection(
+ destination,
+ new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, resolution.Message));
+ return;
+ }
+
+ try
+ {
+ if (!PathsEqual(expectedRoot, resolution.EffectivePath))
+ {
+ ApplyRepositoryWriterInspection(
+ destination,
+ new RepositoryLeaseInspection(
+ RepositoryLeaseState.Invalid,
+ null,
+ "The destination now resolves to a different repository. Check its writer again."));
+ return;
+ }
+
+ RepositoryLeaseInspection current = await Task.Run(
+ () => _repositoryLeaseService.Inspect(resolution.EffectivePath));
+ if (current.State != RepositoryLeaseState.Stale ||
+ current.Lease is null ||
+ !string.Equals(current.Lease.Nonce, expectedNonce, StringComparison.Ordinal))
+ {
+ ApplyRepositoryWriterInspection(destination, current, resolution.EffectivePath);
+ return;
+ }
+
+ string installationId = await Task.Run(_installationIdentityProvider.GetOrCreate);
+ RepositoryLeaseAcquireResult takeover = await Task.Run(() =>
+ _repositoryLeaseService.TakeOverStale(
+ resolution.EffectivePath,
+ expectedNonce,
+ new RepositoryLeaseRequest(
+ installationId,
+ Environment.MachineName,
+ "stale-takeover-confirmation",
+ _appVersion)));
+
+ if (!takeover.Acquired)
+ {
+ ApplyRepositoryWriterInspection(destination, takeover.Inspection, resolution.EffectivePath);
+ return;
+ }
+
+ using (takeover.Handle)
+ {
+ // Acquiring with the inspected nonce atomically records the old
+ // writer as evidence. Releasing immediately lets the user's next
+ // real operation acquire its own correctly named lease.
+ }
+
+ RepositoryLeaseInspection available = await Task.Run(
+ () => _repositoryLeaseService.Inspect(resolution.EffectivePath));
+ ApplyRepositoryWriterInspection(destination, available, resolution.EffectivePath);
+ destination.RepositoryWriterDetails =
+ "The stale writer was preserved as evidence and cleared. The next operation can write safely.";
+ SaveStatus = $"Cleared the stale repository writer for '{destination.DisplayName}'.";
+ }
+ finally
+ {
+ NetworkMountService.Cleanup(resolution);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
+ {
+ ApplyRepositoryWriterInspection(
+ destination,
+ new RepositoryLeaseInspection(RepositoryLeaseState.Unavailable, null, ex.Message));
+ }
+ finally
+ {
+ destination.IsRepositoryWriterBusy = false;
+ }
+ }
+
+ internal static void ApplyRepositoryWriterInspection(
+ BackupDestinationViewModel destination,
+ RepositoryLeaseInspection inspection,
+ string? repositoryRoot = null)
+ {
+ destination.IsWriterTakeoverConfirmationVisible = false;
+ destination.CanReviewStaleWriter = inspection.State == RepositoryLeaseState.Stale && inspection.Lease is not null;
+ destination.StaleWriterNonce = destination.CanReviewStaleWriter
+ ? inspection.Lease!.Nonce
+ : string.Empty;
+ destination.InspectedRepositoryRoot = destination.CanReviewStaleWriter
+ ? repositoryRoot ?? string.Empty
+ : string.Empty;
+
+ destination.RepositoryWriterStatus = inspection.State switch
+ {
+ RepositoryLeaseState.Available => "Available",
+ RepositoryLeaseState.Active => "In use",
+ RepositoryLeaseState.Stale => "Needs review",
+ RepositoryLeaseState.Invalid => "Invalid state",
+ _ => "Unavailable"
+ };
+
+ if (inspection.Lease is null)
+ {
+ destination.RepositoryWriterDetails = inspection.State == RepositoryLeaseState.Available
+ ? "No VaultSync writer currently holds this repository."
+ : inspection.Message;
+ return;
+ }
+
+ RepositoryLeaseSnapshot lease = inspection.Lease;
+ string owner = string.IsNullOrWhiteSpace(lease.HostLabel) ? "Unknown host" : lease.HostLabel;
+ string identity = lease.InstallationId.Length > 8 ? lease.InstallationId[..8] : lease.InstallationId;
+ destination.RepositoryWriterDetails = string.Format(
+ CultureInfo.CurrentCulture,
+ "{0} · identity {1} · {2} · app {3} · heartbeat {4:u} · expires {5:u}",
+ owner,
+ identity,
+ lease.Operation,
+ lease.AppVersion,
+ lease.HeartbeatUtc,
+ lease.ExpiresUtc);
+ }
+
+ private static NetworkCredentialProfile? ResolveCredential(AppConfig config, string? credentialName) =>
+ string.IsNullOrWhiteSpace(credentialName)
+ ? null
+ : config.Network.Credentials.FirstOrDefault(candidate =>
+ candidate.Name.Equals(credentialName, StringComparison.OrdinalIgnoreCase));
+
+ private static bool PathsEqual(string left, string right)
+ {
+ try
+ {
+ StringComparison comparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ return string.Equals(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
+ comparison);
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
index 6820c5bd..7dfd78bc 100644
--- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
+++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
@@ -155,6 +155,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
private readonly CredentialVault _credentialVault = CredentialVault.Instance;
private readonly BackupEncryptionSecretService _backupEncryptionSecretService = new();
private readonly NetworkMountService _networkMountService = new();
+ private readonly RepositoryLeaseService _repositoryLeaseService;
+ private readonly IInstallationIdentityProvider _installationIdentityProvider;
+ private readonly string _appVersion;
private readonly RelayCommand? _addTagColorRuleCommand;
private readonly RelayCommand? _removeTagColorRuleCommand;
private readonly RelayCommand? _resetTagColorRuleCommand;
@@ -557,11 +560,20 @@ private void NotifyLoadedSettingsChanged()
nameof(SaveStatus));
}
- public SettingsViewModel(LocalizationService localizationService, IAppConfigStore? configStore = null, IRepositoryFactory? repositoryFactory = null)
+ public SettingsViewModel(
+ LocalizationService localizationService,
+ IAppConfigStore? configStore = null,
+ IRepositoryFactory? repositoryFactory = null,
+ RepositoryLeaseService? repositoryLeaseService = null,
+ IInstallationIdentityProvider? installationIdentityProvider = null,
+ string? appVersion = null)
{
_localizationService = localizationService;
_configStore = configStore ?? StaticAppConfigStore.Instance;
_repositoryFactory = repositoryFactory ?? new SqliteRepositoryFactory(_configStore);
+ _repositoryLeaseService = repositoryLeaseService ?? new RepositoryLeaseService();
+ _installationIdentityProvider = installationIdentityProvider ?? new InstallationIdentityService();
+ _appVersion = string.IsNullOrWhiteSpace(appVersion) ? "unknown" : appVersion.Trim();
_selectedLanguageCode = localizationService.CurrentLanguage;
_localizationService.LanguageChanged += () =>
{
@@ -627,6 +639,10 @@ public SettingsViewModel(LocalizationService localizationService, IAppConfigStor
RemoveDestinationCommand = new RelayCommand(p => RemoveDestination(p as BackupDestinationViewModel));
BrowseDestinationCommand = new RelayCommand(p => BrowseDestination(p as BackupDestinationViewModel));
TestDestinationCommand = new RelayCommand(p => TestDestination(p as BackupDestinationViewModel));
+ InspectRepositoryWriterCommand = new RelayCommand(p => InspectRepositoryWriter(p as BackupDestinationViewModel));
+ ReviewStaleWriterCommand = new RelayCommand(p => ReviewStaleWriter(p as BackupDestinationViewModel));
+ CancelStaleWriterTakeoverCommand = new RelayCommand(p => CancelStaleWriterTakeover(p as BackupDestinationViewModel));
+ ConfirmStaleWriterTakeoverCommand = new RelayCommand(p => ConfirmStaleWriterTakeover(p as BackupDestinationViewModel));
AddCredentialCommand = new RelayCommand(_ => AddCredential());
RemoveCredentialCommand = new RelayCommand(p => PreviewCredentialRemoval(p as NetworkCredentialViewModel));
_addTagColorRuleCommand = new RelayCommand(_ => AddTagColorRule());
@@ -2752,6 +2768,10 @@ private static string PrependPathEntry(string existing, string entry)
public ICommand RemoveDestinationCommand { get; }
public ICommand BrowseDestinationCommand { get; }
public ICommand TestDestinationCommand { get; }
+ public ICommand InspectRepositoryWriterCommand { get; }
+ public ICommand ReviewStaleWriterCommand { get; }
+ public ICommand CancelStaleWriterTakeoverCommand { get; }
+ public ICommand ConfirmStaleWriterTakeoverCommand { get; }
public ICommand AddCredentialCommand { get; }
public ICommand RemoveCredentialCommand { get; }
public ICommand AddTagColorRuleCommand => _addTagColorRuleCommand!;
@@ -3175,22 +3195,7 @@ private async Task TestDestinationAsync(BackupDestinationViewModel? dest)
: cfg.Network.Credentials.FirstOrDefault(c =>
c.Name.Equals(dest.CredentialName, StringComparison.OrdinalIgnoreCase));
- var destModel = new BackupDestination
- {
- Alias = dest.Alias,
- Path = dest.Path,
- Active = dest.Active,
- PreMounted = dest.PreMounted,
- AutoMount = dest.AutoMount,
- AutoUnmount = dest.AutoUnmount,
- IsOffsite = dest.IsOffsite,
- CredentialName = dest.CredentialName,
- RetryMaxAttempts = ClampInt(dest.RetryMaxAttempts, 1, 10, 1),
- RetryBackoffSeconds = ClampInt(dest.RetryBackoffSeconds, 1, 300, 10),
- EnableCheckpointResume = dest.EnableCheckpointResume,
- SoftQuotaBytes = ToQuotaBytes(dest.SoftQuotaGb),
- QuotaWarningPercent = ClampInt(dest.QuotaWarningPercent, 50, 99, 85)
- };
+ BackupDestination destModel = BuildDestinationModel(dest);
var result = await Task.Run(() =>
{
@@ -3255,6 +3260,23 @@ private async Task TestDestinationAsync(BackupDestinationViewModel? dest)
LocalizationProvider.Service?.GetString("Destinations.Test.Title") ?? "Destination test");
}
+ private static BackupDestination BuildDestinationModel(BackupDestinationViewModel destination) => new()
+ {
+ Alias = destination.Alias,
+ Path = destination.Path,
+ Active = destination.Active,
+ PreMounted = destination.PreMounted,
+ AutoMount = destination.AutoMount,
+ AutoUnmount = destination.AutoUnmount,
+ IsOffsite = destination.IsOffsite,
+ CredentialName = destination.CredentialName,
+ RetryMaxAttempts = ClampInt(destination.RetryMaxAttempts, 1, 10, 1),
+ RetryBackoffSeconds = ClampInt(destination.RetryBackoffSeconds, 1, 300, 10),
+ EnableCheckpointResume = destination.EnableCheckpointResume,
+ SoftQuotaBytes = ToQuotaBytes(destination.SoftQuotaGb),
+ QuotaWarningPercent = ClampInt(destination.QuotaWarningPercent, 50, 99, 85)
+ };
+
private static bool TryWriteProbeFile(string effectivePath)
{
string testFile = Path.Combine(effectivePath, $".vaultsync_destination_test_{Guid.NewGuid():N}");
diff --git a/src/VaultSync.UI/Views/SettingsView.axaml b/src/VaultSync.UI/Views/SettingsView.axaml
index 02482b73..da62f2bd 100644
--- a/src/VaultSync.UI/Views/SettingsView.axaml
+++ b/src/VaultSync.UI/Views/SettingsView.axaml
@@ -994,7 +994,7 @@
CornerRadius="10"
Background="{DynamicResource CardBackgroundBrush}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
diff --git a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
index 5da9dbf2..a12466f1 100644
--- a/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
+++ b/tests/VaultSync.Core.Tests/MetadataSyncTests.cs
@@ -1665,6 +1665,7 @@ public void ExportProjectToStore_RejectsRevisionThatChangedAfterTheLocalBase()
SourceRevision = 1,
Decision = "keep-local",
ResolvedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
+ UndoAvailable = true,
Result = savedBase.Values
});
AppConfigStore.Save(config);
@@ -1681,6 +1682,10 @@ public void ExportProjectToStore_RejectsRevisionThatChangedAfterTheLocalBase()
Assert.Equal(2, secondProvenance["tags"].Revision);
Assert.Equal(1, secondProvenance["restoreMode"].Revision);
Assert.Contains("keep-local", secondRecord.ResolutionJson, StringComparison.Ordinal);
+ ProjectMetadataResolutionRecord supersededResolution =
+ Assert.Single(AppConfigStore.Load().Advanced.ProjectMetadataResolutions);
+ Assert.False(supersededResolution.UndoAvailable);
+ Assert.False(string.IsNullOrWhiteSpace(supersededResolution.SupersededUtc));
secondRecord.SettingsJson = "{\"tags\":\"remote-three\"}";
secondRecord.WriterMachineId = "machine-remote";
From cb750a6dbcbdef514c55c4040e60337143731741 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Mon, 17 Aug 2026 01:13:09 +0200
Subject: [PATCH 53/54] Expose canonical build identity
---
.github/workflows/release-assets.yml | 30 +++++
CHANGELOG.md | 1 +
Directory.Build.props | 15 +++
Localization/strings.ar.json | 5 +
Localization/strings.bn.json | 5 +
Localization/strings.de.json | 5 +
Localization/strings.en.json | 5 +
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 +
README.md | 1 +
ROADMAP.md | 6 +-
docs/RELEASE_1.8.7.md | 12 +-
src/VaultSync.CLI/Commands/VersionCommand.cs | 24 ++--
src/VaultSync.CLI/Program.cs | 13 +-
src/VaultSync.CLI/VaultSync.CLI.csproj | 10 +-
.../Services/BuildInformationService.cs | 125 ++++++++++++++++++
src/VaultSync.UI/Program.cs | 1 +
.../Services/AppBuildInformationService.cs | 25 ++++
.../Services/RecoveryReportExporter.cs | 14 +-
.../Services/SupportBundleService.cs | 16 +--
.../ViewModels/RecoveryViewModel.cs | 7 +-
.../ViewModels/SettingsViewModel.cs | 26 ++++
src/VaultSync.UI/Views/SettingsView.axaml | 34 +++++
.../BuildInformationServiceTests.cs | 81 ++++++++++++
.../RecoveryReportExporterTests.cs | 17 +++
38 files changed, 512 insertions(+), 41 deletions(-)
create mode 100644 src/VaultSync.Core/Services/BuildInformationService.cs
create mode 100644 src/VaultSync.UI/Services/AppBuildInformationService.cs
create mode 100644 tests/VaultSync.Core.Tests/BuildInformationServiceTests.cs
diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml
index b0a3983c..d738af51 100644
--- a/.github/workflows/release-assets.yml
+++ b/.github/workflows/release-assets.yml
@@ -283,6 +283,12 @@ jobs:
-f net10.0-windows10.0.19041.0
-r win-x64
--self-contained true
+ -p:VaultSyncReleaseChannel=stable
+ -p:VaultSyncPackageKind=windows-installer
+ -p:VaultSyncUpdateSource=github
+ -p:VaultSyncOfficialBuild=true
+ -p:VaultSyncSignatureStatus=unsigned
+ -p:SourceRevisionId=${{ github.sha }}
- name: Verify embedded runtime (win-x64)
run: >
@@ -440,6 +446,12 @@ jobs:
-f net10.0
-r osx-arm64
--self-contained true
+ -p:VaultSyncReleaseChannel=stable
+ -p:VaultSyncPackageKind=macos-dmg
+ -p:VaultSyncUpdateSource=github
+ -p:VaultSyncOfficialBuild=true
+ -p:VaultSyncSignatureStatus=unsigned
+ -p:SourceRevisionId=${{ github.sha }}
- name: Publish (osx-x64)
run: >
@@ -448,6 +460,12 @@ jobs:
-f net10.0
-r osx-x64
--self-contained true
+ -p:VaultSyncReleaseChannel=stable
+ -p:VaultSyncPackageKind=macos-dmg
+ -p:VaultSyncUpdateSource=github
+ -p:VaultSyncOfficialBuild=true
+ -p:VaultSyncSignatureStatus=unsigned
+ -p:SourceRevisionId=${{ github.sha }}
- name: Verify embedded runtimes (macOS)
run: >
@@ -545,6 +563,12 @@ jobs:
-f net10.0
-r linux-x64
--self-contained true
+ -p:VaultSyncReleaseChannel=stable
+ -p:VaultSyncPackageKind=linux-multi-format
+ -p:VaultSyncUpdateSource=github
+ -p:VaultSyncOfficialBuild=true
+ -p:VaultSyncSignatureStatus=unsigned
+ -p:SourceRevisionId=${{ github.sha }}
- name: Publish (linux-arm64)
run: >
@@ -553,6 +577,12 @@ jobs:
-f net10.0
-r linux-arm64
--self-contained true
+ -p:VaultSyncReleaseChannel=stable
+ -p:VaultSyncPackageKind=linux-multi-format
+ -p:VaultSyncUpdateSource=github
+ -p:VaultSyncOfficialBuild=true
+ -p:VaultSyncSignatureStatus=unsigned
+ -p:SourceRevisionId=${{ github.sha }}
- name: Verify embedded runtimes (Linux)
run: >
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84ad594b..76de57c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [1.8.7] - Unreleased
### Added
+- [VS-1871] Added one conservative build-identity record across Settings, startup diagnostics, support and recovery exports, plus `vaultsync --version --json`; version, channel, commit, runtime, architecture, package, update source, official status, and signature status now come from the same contract.
- [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/Directory.Build.props b/Directory.Build.props
index 30b039fd..dd1efa68 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,8 +3,23 @@
10.0.11
true
+
+ development
+ development
+ none
+ false
+ unknown
+
+
+
+
+
+
+
+
+
diff --git a/Localization/strings.ar.json b/Localization/strings.ar.json
index b2062eb5..ebc4151c 100644
--- a/Localization/strings.ar.json
+++ b/Localization/strings.ar.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "إرسال إحصائيات الاستخدام مجهولة المصدر",
"Settings.Advanced.SendUsageStatsDescription": "ساعد في تحسين VaultSync من خلال مشاركة المقاييس الأساسية مجهولة المصدر.",
"Settings.Advanced.Title": "متقدم",
+ "Settings.Advanced.BuildInformationTitle": "معلومات الإصدار",
+ "Settings.Advanced.BuildInformationDescription": "الهوية الدقيقة لإصدار VaultSync قيد التشغيل.",
+ "Settings.Advanced.BuildInformationCopy": "نسخ",
+ "Settings.Advanced.BuildInformationCopied": "تم نسخ معلومات الإصدار.",
+ "Settings.Advanced.BuildInformationCopyFailed": "تعذر نسخ معلومات الإصدار.",
"Settings.Advanced.UpdateInterval": "الفاصل الزمني للتحقق من التحديث (بالدقائق)",
"Settings.Advanced.UpdateIntervalDescription": "عدد مرات قيام VaultSync بالتحقق من التحديثات أثناء التشغيل.",
"Settings.Advanced.UpdateStatusError": "آخر خطأ: {0}",
diff --git a/Localization/strings.bn.json b/Localization/strings.bn.json
index 73f978a2..b17a6a97 100644
--- a/Localization/strings.bn.json
+++ b/Localization/strings.bn.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "নামহীন ব্যবহার পরিসংখ্যান পাঠান",
"Settings.Advanced.SendUsageStatsDescription": "মৌলিক, নামহীন মেট্রিক শেয়ার করে VaultSync উন্নত করতে সহায়তা করুন।",
"Settings.Advanced.Title": "উন্নত",
+ "Settings.Advanced.BuildInformationTitle": "বিল্ড তথ্য",
+ "Settings.Advanced.BuildInformationDescription": "চলমান VaultSync বিল্ডটির সঠিক পরিচয়।",
+ "Settings.Advanced.BuildInformationCopy": "কপি করুন",
+ "Settings.Advanced.BuildInformationCopied": "বিল্ড তথ্য কপি করা হয়েছে।",
+ "Settings.Advanced.BuildInformationCopyFailed": "বিল্ড তথ্য কপি করা যায়নি।",
"Settings.Advanced.UpdateInterval": "আপডেট পরীক্ষা ব্যবধান (মিনিট)",
"Settings.Advanced.UpdateIntervalDescription": "VaultSync চালু থাকা অবস্থায় কত ঘন ঘন আপডেট পরীক্ষা করবে।",
"Settings.Advanced.UpdateStatusError": "সর্বশেষ ত্রুটি: {0}",
diff --git a/Localization/strings.de.json b/Localization/strings.de.json
index 238344b2..5d1770c5 100644
--- a/Localization/strings.de.json
+++ b/Localization/strings.de.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Anonyme Nutzungsstatistiken senden",
"Settings.Advanced.SendUsageStatsDescription": "Hilf VaultSync mit anonymisierten Basisdaten.",
"Settings.Advanced.Title": "Erweitert",
+ "Settings.Advanced.BuildInformationTitle": "Build-Informationen",
+ "Settings.Advanced.BuildInformationDescription": "Exakte Identität dieser laufenden VaultSync-Version.",
+ "Settings.Advanced.BuildInformationCopy": "Kopieren",
+ "Settings.Advanced.BuildInformationCopied": "Build-Informationen kopiert.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Build-Informationen konnten nicht kopiert werden.",
"Settings.Advanced.UpdateInterval": "Update-Prüfintervall (Minuten)",
"Settings.Advanced.UpdateIntervalDescription": "Wie oft VaultSync während der Ausführung nach Updates sucht.",
"Settings.Advanced.UpdateStatusError": "Letzter Fehler: {0}",
diff --git a/Localization/strings.en.json b/Localization/strings.en.json
index dc62ae60..47b8aa68 100644
--- a/Localization/strings.en.json
+++ b/Localization/strings.en.json
@@ -1099,6 +1099,11 @@
"Settings.Advanced.SendUsageStats": "Send anonymous usage stats",
"Settings.Advanced.SendUsageStatsDescription": "Help improve VaultSync by sharing basic, anonymised metrics.",
"Settings.Advanced.Title": "Advanced",
+ "Settings.Advanced.BuildInformationTitle": "Build information",
+ "Settings.Advanced.BuildInformationDescription": "Exact identity of this running VaultSync build.",
+ "Settings.Advanced.BuildInformationCopy": "Copy",
+ "Settings.Advanced.BuildInformationCopied": "Build information copied.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Could not copy build information.",
"Settings.Advanced.UpdateInterval": "Update check interval (minutes)",
"Settings.Advanced.UpdateIntervalDescription": "How often VaultSync checks for updates while running.",
"Settings.Advanced.UpdateStatusError": "Last error: {0}",
diff --git a/Localization/strings.es.json b/Localization/strings.es.json
index ad36e2d5..17144907 100644
--- a/Localization/strings.es.json
+++ b/Localization/strings.es.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Enviar estadísticas de uso anónimas",
"Settings.Advanced.SendUsageStatsDescription": "Ayuda a mejorar VaultSync compartiendo métricas básicas y anónimas.",
"Settings.Advanced.Title": "Avanzado",
+ "Settings.Advanced.BuildInformationTitle": "Información de compilación",
+ "Settings.Advanced.BuildInformationDescription": "Identidad exacta de esta compilación de VaultSync en ejecución.",
+ "Settings.Advanced.BuildInformationCopy": "Copiar",
+ "Settings.Advanced.BuildInformationCopied": "Información de compilación copiada.",
+ "Settings.Advanced.BuildInformationCopyFailed": "No se pudo copiar la información de compilación.",
"Settings.Advanced.UpdateInterval": "Intervalo de búsqueda (minutos)",
"Settings.Advanced.UpdateIntervalDescription": "Cada cuánto VaultSync busca actualizaciones mientras está en ejecución.",
"Settings.Advanced.UpdateStatusError": "Último error: {0}",
diff --git a/Localization/strings.fr.json b/Localization/strings.fr.json
index d57bd42e..34348d7f 100644
--- a/Localization/strings.fr.json
+++ b/Localization/strings.fr.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Envoyer des statistiques d'utilisation anonymes",
"Settings.Advanced.SendUsageStatsDescription": "Aidez à améliorer VaultSync en partageant des métriques de base anonymisées.",
"Settings.Advanced.Title": "Avancé",
+ "Settings.Advanced.BuildInformationTitle": "Informations de build",
+ "Settings.Advanced.BuildInformationDescription": "Identité exacte de cette version de VaultSync en cours d’exécution.",
+ "Settings.Advanced.BuildInformationCopy": "Copier",
+ "Settings.Advanced.BuildInformationCopied": "Informations de build copiées.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Impossible de copier les informations de build.",
"Settings.Advanced.UpdateInterval": "Intervalle de vérification (minutes)",
"Settings.Advanced.UpdateIntervalDescription": "Fréquence des vérifications pendant l’exécution.",
"Settings.Advanced.UpdateStatusError": "Dernière erreur : {0}",
diff --git a/Localization/strings.hi.json b/Localization/strings.hi.json
index 9d85858d..5e9b0c11 100644
--- a/Localization/strings.hi.json
+++ b/Localization/strings.hi.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "अनाम उपयोग आँकड़े भेजें",
"Settings.Advanced.SendUsageStatsDescription": "मूल, गुमनाम मीट्रिक साझा कर VaultSync को बेहतर बनाने में मदद करें।",
"Settings.Advanced.Title": "उन्नत",
+ "Settings.Advanced.BuildInformationTitle": "बिल्ड जानकारी",
+ "Settings.Advanced.BuildInformationDescription": "चल रहे इस VaultSync बिल्ड की सटीक पहचान।",
+ "Settings.Advanced.BuildInformationCopy": "कॉपी करें",
+ "Settings.Advanced.BuildInformationCopied": "बिल्ड जानकारी कॉपी की गई।",
+ "Settings.Advanced.BuildInformationCopyFailed": "बिल्ड जानकारी कॉपी नहीं की जा सकी।",
"Settings.Advanced.UpdateInterval": "अपडेट जाँच अंतराल (मिनट)",
"Settings.Advanced.UpdateIntervalDescription": "चलते समय VaultSync कितनी बार अपडेट जाँचता है।",
"Settings.Advanced.UpdateStatusError": "अंतिम त्रुटि: {0}",
diff --git a/Localization/strings.id.json b/Localization/strings.id.json
index 2d830849..afd78c97 100644
--- a/Localization/strings.id.json
+++ b/Localization/strings.id.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Kirim statistik penggunaan anonim",
"Settings.Advanced.SendUsageStatsDescription": "Bantu tingkatkan VaultSync dengan membagikan metrik dasar yang dianonimkan.",
"Settings.Advanced.Title": "Lanjutan",
+ "Settings.Advanced.BuildInformationTitle": "Informasi build",
+ "Settings.Advanced.BuildInformationDescription": "Identitas persis build VaultSync yang sedang berjalan.",
+ "Settings.Advanced.BuildInformationCopy": "Salin",
+ "Settings.Advanced.BuildInformationCopied": "Informasi build disalin.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Informasi build tidak dapat disalin.",
"Settings.Advanced.UpdateInterval": "Interval pemeriksaan pembaruan (menit)",
"Settings.Advanced.UpdateIntervalDescription": "Seberapa sering VaultSync memeriksa pembaruan saat berjalan.",
"Settings.Advanced.UpdateStatusError": "Kesalahan terakhir: {0}",
diff --git a/Localization/strings.it.json b/Localization/strings.it.json
index 66773768..817b2358 100644
--- a/Localization/strings.it.json
+++ b/Localization/strings.it.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Invia statistiche d'uso anonime",
"Settings.Advanced.SendUsageStatsDescription": "Aiuta a migliorare VaultSync condividendo metriche di base anonime.",
"Settings.Advanced.Title": "Avanzate",
+ "Settings.Advanced.BuildInformationTitle": "Informazioni sulla build",
+ "Settings.Advanced.BuildInformationDescription": "Identità esatta di questa build di VaultSync in esecuzione.",
+ "Settings.Advanced.BuildInformationCopy": "Copia",
+ "Settings.Advanced.BuildInformationCopied": "Informazioni sulla build copiate.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Impossibile copiare le informazioni sulla build.",
"Settings.Advanced.UpdateInterval": "Intervallo controllo aggiornamenti (minuti)",
"Settings.Advanced.UpdateIntervalDescription": "Quanto spesso VaultSync controlla gli aggiornamenti mentre è in esecuzione.",
"Settings.Advanced.UpdateStatusError": "Ultimo errore: {0}",
diff --git a/Localization/strings.ja.json b/Localization/strings.ja.json
index a9b0de5a..82fa4969 100644
--- a/Localization/strings.ja.json
+++ b/Localization/strings.ja.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "匿名の使用統計を送信",
"Settings.Advanced.SendUsageStatsDescription": "基本で匿名化された指標を共有してVaultSyncの改善に協力しましょう。",
"Settings.Advanced.Title": "上級",
+ "Settings.Advanced.BuildInformationTitle": "ビルド情報",
+ "Settings.Advanced.BuildInformationDescription": "実行中の VaultSync ビルドを正確に識別する情報です。",
+ "Settings.Advanced.BuildInformationCopy": "コピー",
+ "Settings.Advanced.BuildInformationCopied": "ビルド情報をコピーしました。",
+ "Settings.Advanced.BuildInformationCopyFailed": "ビルド情報をコピーできませんでした。",
"Settings.Advanced.UpdateInterval": "更新チェック間隔(分)",
"Settings.Advanced.UpdateIntervalDescription": "VaultSyncは実行中に更新をチェックしている頻度について。",
"Settings.Advanced.UpdateStatusError": "最後のエラー:{0}",
diff --git a/Localization/strings.ko.json b/Localization/strings.ko.json
index 5038ec76..866f80be 100644
--- a/Localization/strings.ko.json
+++ b/Localization/strings.ko.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "익명 사용 통계 전송",
"Settings.Advanced.SendUsageStatsDescription": "기본적이고 익명화된 지표를 공유하여 VaultSync 개선에 도움을 주세요.",
"Settings.Advanced.Title": "고급",
+ "Settings.Advanced.BuildInformationTitle": "빌드 정보",
+ "Settings.Advanced.BuildInformationDescription": "현재 실행 중인 VaultSync 빌드의 정확한 식별 정보입니다.",
+ "Settings.Advanced.BuildInformationCopy": "복사",
+ "Settings.Advanced.BuildInformationCopied": "빌드 정보를 복사했습니다.",
+ "Settings.Advanced.BuildInformationCopyFailed": "빌드 정보를 복사할 수 없습니다.",
"Settings.Advanced.UpdateInterval": "업데이트 체크 간격 (분)",
"Settings.Advanced.UpdateIntervalDescription": "VaultSync가 실행 중에 업데이트를 얼마나 자주 확인하나요?",
"Settings.Advanced.UpdateStatusError": "마지막 실수: {0}",
diff --git a/Localization/strings.nl.json b/Localization/strings.nl.json
index 08ef07f1..5389f01b 100644
--- a/Localization/strings.nl.json
+++ b/Localization/strings.nl.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Stuur anonieme gebruiksstatistieken",
"Settings.Advanced.SendUsageStatsDescription": "Help VaultSync te verbeteren door basis, geanonimiseerde statistieken te delen.",
"Settings.Advanced.Title": "Geavanceerd",
+ "Settings.Advanced.BuildInformationTitle": "Buildinformatie",
+ "Settings.Advanced.BuildInformationDescription": "Exacte identiteit van deze actieve VaultSync-build.",
+ "Settings.Advanced.BuildInformationCopy": "Kopiëren",
+ "Settings.Advanced.BuildInformationCopied": "Buildinformatie gekopieerd.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Kan buildinformatie niet kopiëren.",
"Settings.Advanced.UpdateInterval": "Update-controleinterval (minuten)",
"Settings.Advanced.UpdateIntervalDescription": "Hoe vaak VaultSync controleert op updates tijdens het draaien.",
"Settings.Advanced.UpdateStatusError": "Laatste fout: {0}",
diff --git a/Localization/strings.pl.json b/Localization/strings.pl.json
index 08ea5a85..34c34d74 100644
--- a/Localization/strings.pl.json
+++ b/Localization/strings.pl.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Wyślij anonimowe statystyki zużycia",
"Settings.Advanced.SendUsageStatsDescription": "Pomóż ulepszyć VaultSync, udostępniając podstawowe, zanonimizowane metryki.",
"Settings.Advanced.Title": "Zaawansowane",
+ "Settings.Advanced.BuildInformationTitle": "Informacje o kompilacji",
+ "Settings.Advanced.BuildInformationDescription": "Dokładna tożsamość uruchomionej kompilacji VaultSync.",
+ "Settings.Advanced.BuildInformationCopy": "Kopiuj",
+ "Settings.Advanced.BuildInformationCopied": "Skopiowano informacje o kompilacji.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Nie można skopiować informacji o kompilacji.",
"Settings.Advanced.UpdateInterval": "Aktualizacja interwału sprawdzania (minuty)",
"Settings.Advanced.UpdateIntervalDescription": "Jak często VaultSync sprawdza aktualizacje podczas działania?",
"Settings.Advanced.UpdateStatusError": "Ostatni błąd: {0}",
diff --git a/Localization/strings.pt.json b/Localization/strings.pt.json
index 586e6ea9..26a1c771 100644
--- a/Localization/strings.pt.json
+++ b/Localization/strings.pt.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Enviar estatísticas de uso anônimas",
"Settings.Advanced.SendUsageStatsDescription": "Ajude a melhorar o VaultSync compartilhando métricas básicas e anônimas.",
"Settings.Advanced.Title": "Avançado",
+ "Settings.Advanced.BuildInformationTitle": "Informações da compilação",
+ "Settings.Advanced.BuildInformationDescription": "Identidade exata desta compilação do VaultSync em execução.",
+ "Settings.Advanced.BuildInformationCopy": "Copiar",
+ "Settings.Advanced.BuildInformationCopied": "Informações da compilação copiadas.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Não foi possível copiar as informações da compilação.",
"Settings.Advanced.UpdateInterval": "Intervalo de verificação (minutos)",
"Settings.Advanced.UpdateIntervalDescription": "Com que frequência o VaultSync verifica atualizações enquanto estiver em execução.",
"Settings.Advanced.UpdateStatusError": "Último erro: {0}",
diff --git a/Localization/strings.ru.json b/Localization/strings.ru.json
index cd81dab4..0cc3bf6f 100644
--- a/Localization/strings.ru.json
+++ b/Localization/strings.ru.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Отправлять анонимную статистику использования",
"Settings.Advanced.SendUsageStatsDescription": "Помогает улучшать VaultSync, отправляя обезличенные данные.",
"Settings.Advanced.Title": "Дополнительно",
+ "Settings.Advanced.BuildInformationTitle": "Сведения о сборке",
+ "Settings.Advanced.BuildInformationDescription": "Точные сведения о запущенной сборке VaultSync.",
+ "Settings.Advanced.BuildInformationCopy": "Копировать",
+ "Settings.Advanced.BuildInformationCopied": "Сведения о сборке скопированы.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Не удалось скопировать сведения о сборке.",
"Settings.Advanced.UpdateInterval": "Интервал проверки обновлений (минуты)",
"Settings.Advanced.UpdateIntervalDescription": "Как часто VaultSync проверяет обновления во время работы.",
"Settings.Advanced.UpdateStatusError": "Последняя ошибка: {0}",
diff --git a/Localization/strings.tr.json b/Localization/strings.tr.json
index 3df8d10c..17f5d640 100644
--- a/Localization/strings.tr.json
+++ b/Localization/strings.tr.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Anonim kullanım istatistikleri gönderin",
"Settings.Advanced.SendUsageStatsDescription": "Temel, anonimleştirilmiş metrikleri paylaşarak VaultSync'i geliştirmeye yardımcı olun.",
"Settings.Advanced.Title": "İleri",
+ "Settings.Advanced.BuildInformationTitle": "Derleme bilgileri",
+ "Settings.Advanced.BuildInformationDescription": "Çalışan bu VaultSync derlemesinin tam kimliği.",
+ "Settings.Advanced.BuildInformationCopy": "Kopyala",
+ "Settings.Advanced.BuildInformationCopied": "Derleme bilgileri kopyalandı.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Derleme bilgileri kopyalanamadı.",
"Settings.Advanced.UpdateInterval": "Güncelleme kontrol aralığı (dakikalar)",
"Settings.Advanced.UpdateIntervalDescription": "VaultSync çalışırken güncellemeleri ne sıklıkla kontrol ediyor?",
"Settings.Advanced.UpdateStatusError": "Son hata: {0}",
diff --git a/Localization/strings.uk.json b/Localization/strings.uk.json
index ef89a8c6..ad3d393d 100644
--- a/Localization/strings.uk.json
+++ b/Localization/strings.uk.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Надсилайте анонімні дані про використання",
"Settings.Advanced.SendUsageStatsDescription": "Допоможіть покращити VaultSync, ділячись базовими, анонімізованими метриками.",
"Settings.Advanced.Title": "Просунуті",
+ "Settings.Advanced.BuildInformationTitle": "Відомості про збірку",
+ "Settings.Advanced.BuildInformationDescription": "Точні відомості про запущену збірку VaultSync.",
+ "Settings.Advanced.BuildInformationCopy": "Копіювати",
+ "Settings.Advanced.BuildInformationCopied": "Відомості про збірку скопійовано.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Не вдалося скопіювати відомості про збірку.",
"Settings.Advanced.UpdateInterval": "Інтервал перевірки оновлень (хвилини)",
"Settings.Advanced.UpdateIntervalDescription": "Як часто VaultSync перевіряє оновлення під час запуску.",
"Settings.Advanced.UpdateStatusError": "Остання помилка: {0}",
diff --git a/Localization/strings.vi.json b/Localization/strings.vi.json
index ae982040..5b6ed9f7 100644
--- a/Localization/strings.vi.json
+++ b/Localization/strings.vi.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "Gửi số liệu thống kê sử dụng ẩn danh",
"Settings.Advanced.SendUsageStatsDescription": "Giúp cải thiện VaultSync bằng cách chia sẻ các chỉ số cơ bản, ẩn danh.",
"Settings.Advanced.Title": "Nâng cao",
+ "Settings.Advanced.BuildInformationTitle": "Thông tin bản dựng",
+ "Settings.Advanced.BuildInformationDescription": "Danh tính chính xác của bản dựng VaultSync đang chạy.",
+ "Settings.Advanced.BuildInformationCopy": "Sao chép",
+ "Settings.Advanced.BuildInformationCopied": "Đã sao chép thông tin bản dựng.",
+ "Settings.Advanced.BuildInformationCopyFailed": "Không thể sao chép thông tin bản dựng.",
"Settings.Advanced.UpdateInterval": "Cập nhật khoảng thời gian kiểm tra (phút)",
"Settings.Advanced.UpdateIntervalDescription": "Tần suất VaultSync kiểm tra các bản cập nhật trong khi chạy.",
"Settings.Advanced.UpdateStatusError": "Lỗi cuối cùng: {0}",
diff --git a/Localization/strings.zh.json b/Localization/strings.zh.json
index 78a89f4f..891c04d5 100644
--- a/Localization/strings.zh.json
+++ b/Localization/strings.zh.json
@@ -1101,6 +1101,11 @@
"Settings.Advanced.SendUsageStats": "发送匿名使用统计",
"Settings.Advanced.SendUsageStatsDescription": "通过发送匿名使用数据帮助改进 VaultSync。",
"Settings.Advanced.Title": "高级",
+ "Settings.Advanced.BuildInformationTitle": "构建信息",
+ "Settings.Advanced.BuildInformationDescription": "当前运行的 VaultSync 构建的准确标识。",
+ "Settings.Advanced.BuildInformationCopy": "复制",
+ "Settings.Advanced.BuildInformationCopied": "已复制构建信息。",
+ "Settings.Advanced.BuildInformationCopyFailed": "无法复制构建信息。",
"Settings.Advanced.UpdateInterval": "更新检查间隔(分钟)",
"Settings.Advanced.UpdateIntervalDescription": "VaultSync 在运行期间检查更新的频率。",
"Settings.Advanced.UpdateStatusError": "上次错误:{0}",
diff --git a/README.md b/README.md
index 0d44b4e4..f5dcc0cb 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,7 @@ VaultSync includes curated dark and light visual presets with optional advanced
- Review metadata conflicts before accepting changes.
- Receive destination quota suggestions and schedule maintenance jobs.
- Export support bundles and inspect strict patch compatibility diagnostics.
+- Copy the exact running build identity from Settings, or print the same machine-readable record with `vaultsync --version --json`.
- Prepare optional, fully reviewable crash-report email drafts. Nothing is uploaded or sent automatically.
### Encrypt
diff --git a/ROADMAP.md b/ROADMAP.md
index cf810290..00108728 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -391,7 +391,7 @@ 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,
+- [x] `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,
@@ -400,6 +400,10 @@ after the planned minor train is complete and use explicit beta qualification.
- 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.
+ - Completed 2026-08-17: one schema-versioned record now drives Settings copy,
+ startup diagnostics, support bundles, recovery reports, and CLI JSON. Release
+ publishes stamp the source commit and distribution facts; unstamped or
+ incomplete builds cannot present themselves as official.
- [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,
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 0d58427e..04647670 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -118,6 +118,12 @@ complete and stable enough for broader qualification.
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`).
+- The running build now exposes one schema-versioned identity in Settings,
+ startup diagnostics, support bundles, recovery reports, and
+ `vaultsync --version --json`. Release artifacts stamp their channel, commit,
+ package, update source, official status, and honest signature state; missing
+ facts remain `unknown` and incomplete builds cannot claim official status
+ (`VS-1871`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
@@ -128,11 +134,9 @@ branch package.
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
+2. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB
behavior before enabling supported concurrent-machine workflows.
-4. Reduce codebase duplication and oversized orchestration through shared,
+3. Reduce codebase duplication and oversized orchestration through shared,
regression-tested primitives without combining genuinely different platform
behavior.
diff --git a/src/VaultSync.CLI/Commands/VersionCommand.cs b/src/VaultSync.CLI/Commands/VersionCommand.cs
index 8bf2bf93..25a7b55b 100644
--- a/src/VaultSync.CLI/Commands/VersionCommand.cs
+++ b/src/VaultSync.CLI/Commands/VersionCommand.cs
@@ -1,22 +1,28 @@
-using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
-using Spectre.Console;
using Spectre.Console.Cli;
+using VaultSync.Core.Services;
namespace VaultSync.CLI.Commands
{
- sealed class VersionSettings : CommandSettings;
+ public sealed class VersionSettings : CommandSettings
+ {
+ [CommandOption("--json")]
+ public bool Json { get; init; }
+ }
- sealed class VersionCommand : AsyncCommand
+ public sealed class VersionCommand : AsyncCommand
{
protected override Task ExecuteAsync(CommandContext context, VersionSettings s, CancellationToken cancellationToken)
{
- var asm = Assembly.GetExecutingAssembly();
- string? informational = asm.GetCustomAttribute()?.InformationalVersion;
- string version = informational ?? asm.GetName().Version?.ToString() ?? "0.0.0";
- AnsiConsole.MarkupLine($"VaultSync CLI v{Markup.Escape(version)}");
+ Write(s.Json);
return Task.FromResult(0);
}
+
+ public static void Write(bool json)
+ {
+ BuildInformation information = BuildInformationService.Create(typeof(VersionCommand).Assembly);
+ Console.WriteLine(json ? information.ToJson(indented: true) : information.ToDisplayText());
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/VaultSync.CLI/Program.cs b/src/VaultSync.CLI/Program.cs
index 63d78a4a..60a3a52c 100644
--- a/src/VaultSync.CLI/Program.cs
+++ b/src/VaultSync.CLI/Program.cs
@@ -12,9 +12,16 @@ namespace VaultSync.CLI;
// All commands, helpers, and utilities now live in separate files/namespaces.
public static class Program
{
- public static async Task Main(string[] args)
- {
- // Initialize logging (implemented in Utils/Log.cs)
+ public static async Task Main(string[] args)
+ {
+ if (args.Length > 0 && string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase))
+ {
+ bool json = args.Skip(1).Any(arg => string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase));
+ VaultSync.CLI.Commands.VersionCommand.Write(json);
+ return 0;
+ }
+
+ // Initialize logging (implemented in Utils/Log.cs)
Log.Init();
try
diff --git a/src/VaultSync.CLI/VaultSync.CLI.csproj b/src/VaultSync.CLI/VaultSync.CLI.csproj
index 65a17f26..bcd6ab0c 100644
--- a/src/VaultSync.CLI/VaultSync.CLI.csproj
+++ b/src/VaultSync.CLI/VaultSync.CLI.csproj
@@ -21,11 +21,11 @@
vaultsync.cli
- 0.8.1
- 0.8.1
- 0.8.1.0
- 0.8.1.0
- 0.8.1
+ 1.8.6
+ 1.8.6
+ 1.8.6.0
+ 1.8.6.0
+ 1.8.6
Flavio Giacchetti
Flavio Giacchetti
diff --git a/src/VaultSync.Core/Services/BuildInformationService.cs b/src/VaultSync.Core/Services/BuildInformationService.cs
new file mode 100644
index 00000000..1bb7c9ec
--- /dev/null
+++ b/src/VaultSync.Core/Services/BuildInformationService.cs
@@ -0,0 +1,125 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+
+namespace VaultSync.Core.Services;
+
+public sealed record BuildInformation(
+ int SchemaVersion,
+ string Product,
+ string Version,
+ string ReleaseChannel,
+ string SourceCommit,
+ string Runtime,
+ string RuntimeIdentifier,
+ string Architecture,
+ string OperatingSystem,
+ string PackageKind,
+ string UpdateSource,
+ bool OfficialBuild,
+ string SignatureStatus)
+{
+ public const string Unknown = "unknown";
+
+ public string ToJson(bool indented = false) => JsonSerializer.Serialize(this, JsonOptions(indented));
+
+ public string ToDisplayText() => string.Join(Environment.NewLine,
+ $"Product: {Product}",
+ $"Version: {Version}",
+ $"Channel: {ReleaseChannel}",
+ $"Commit: {SourceCommit}",
+ $"Runtime: {Runtime}",
+ $"Runtime identifier: {RuntimeIdentifier}",
+ $"Architecture: {Architecture}",
+ $"Operating system: {OperatingSystem}",
+ $"Package: {PackageKind}",
+ $"Updates: {UpdateSource}",
+ $"Official build: {(OfficialBuild ? "yes" : "no")}",
+ $"Signature: {SignatureStatus}");
+
+ private static JsonSerializerOptions JsonOptions(bool indented) => new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = indented
+ };
+}
+
+public sealed record BuildInformationOverrides(
+ string? PackageKind = null,
+ string? UpdateSource = null,
+ string? ReleaseChannel = null,
+ string? SignatureStatus = null,
+ string? RuntimeIdentifier = null,
+ string? Architecture = null,
+ string? OperatingSystem = null);
+
+public static class BuildInformationService
+{
+ private const string ProductName = "VaultSync";
+
+ public static BuildInformation Create(Assembly assembly, BuildInformationOverrides? overrides = null)
+ {
+ ArgumentNullException.ThrowIfNull(assembly);
+ overrides ??= new BuildInformationOverrides();
+
+ Dictionary metadata = assembly
+ .GetCustomAttributes()
+ .Where(item => !string.IsNullOrWhiteSpace(item.Key))
+ .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.Last().Value ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+ string informational = assembly.GetCustomAttribute()?.InformationalVersion ?? string.Empty;
+ string version = Normalize(informational.Split('+', 2)[0]);
+ if (version == BuildInformation.Unknown)
+ version = Normalize(assembly.GetName().Version?.ToString());
+
+ string sourceCommit = Normalize(Metadata(metadata, "VaultSyncSourceCommit"));
+ if (sourceCommit == BuildInformation.Unknown)
+ sourceCommit = ExtractCommit(informational);
+
+ string channel = Normalize(overrides.ReleaseChannel ?? Metadata(metadata, "VaultSyncReleaseChannel"));
+ string packageKind = Normalize(overrides.PackageKind ?? Metadata(metadata, "VaultSyncPackageKind"));
+ string updateSource = Normalize(overrides.UpdateSource ?? Metadata(metadata, "VaultSyncUpdateSource"));
+ string signatureStatus = Normalize(overrides.SignatureStatus ?? Metadata(metadata, "VaultSyncSignatureStatus"));
+
+ bool officialRequested = bool.TryParse(Metadata(metadata, "VaultSyncOfficialBuild"), out bool official) && official;
+ bool officialBuild = officialRequested &&
+ version != BuildInformation.Unknown &&
+ channel != BuildInformation.Unknown &&
+ sourceCommit != BuildInformation.Unknown &&
+ packageKind != BuildInformation.Unknown;
+
+ return new BuildInformation(
+ SchemaVersion: 1,
+ Product: ProductName,
+ Version: version,
+ ReleaseChannel: channel,
+ SourceCommit: sourceCommit,
+ Runtime: Normalize(RuntimeInformation.FrameworkDescription),
+ RuntimeIdentifier: Normalize(overrides.RuntimeIdentifier ?? RuntimeInformation.RuntimeIdentifier),
+ Architecture: Normalize(overrides.Architecture ?? RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()),
+ OperatingSystem: Normalize(overrides.OperatingSystem ?? RuntimeInformation.OSDescription),
+ PackageKind: packageKind,
+ UpdateSource: updateSource,
+ OfficialBuild: officialBuild,
+ SignatureStatus: signatureStatus);
+ }
+
+ private static string Metadata(IReadOnlyDictionary metadata, string key) =>
+ metadata.TryGetValue(key, out string? value) ? value : string.Empty;
+
+ private static string Normalize(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? BuildInformation.Unknown : value.Trim();
+
+ private static string ExtractCommit(string informational)
+ {
+ int separator = informational.LastIndexOf('+');
+ if (separator < 0 || separator == informational.Length - 1)
+ return BuildInformation.Unknown;
+
+ string candidate = informational[(separator + 1)..].Trim();
+ return candidate.Length is >= 7 and <= 64 && candidate.All(Uri.IsHexDigit)
+ ? candidate
+ : BuildInformation.Unknown;
+ }
+}
diff --git a/src/VaultSync.UI/Program.cs b/src/VaultSync.UI/Program.cs
index 2c60d928..ed4580b5 100644
--- a/src/VaultSync.UI/Program.cs
+++ b/src/VaultSync.UI/Program.cs
@@ -30,6 +30,7 @@ public static void Main(string[] args)
RestrictPrivateDataRoots();
DiagnosticsLogger.Initialize();
DiagnosticsLogger.Record($"Process start. PID={Environment.ProcessId}, Args='{string.Join(' ', args)}'.");
+ DiagnosticsLogger.Record($"Build identity: {AppBuildInformationService.Current.ToJson()}.");
LogParentProcessInfo("startup");
RegisterPosixSignals();
RegisterDiagnosticHooks(args);
diff --git a/src/VaultSync.UI/Services/AppBuildInformationService.cs b/src/VaultSync.UI/Services/AppBuildInformationService.cs
new file mode 100644
index 00000000..527c3958
--- /dev/null
+++ b/src/VaultSync.UI/Services/AppBuildInformationService.cs
@@ -0,0 +1,25 @@
+using System;
+using VaultSync.Core.Services;
+
+namespace VaultSync.UI.Services;
+
+public static class AppBuildInformationService
+{
+ private static readonly Lazy s_current = new(Create);
+
+ public static BuildInformation Current => s_current.Value;
+
+ private static BuildInformation Create()
+ {
+ AppDistributionInfo distribution = DistributionChannelService.Current;
+ BuildInformation initial = BuildInformationService.Create(typeof(AppBuildInformationService).Assembly);
+ if (!distribution.IsStore)
+ return initial;
+
+ return BuildInformationService.Create(
+ typeof(AppBuildInformationService).Assembly,
+ new BuildInformationOverrides(
+ PackageKind: "microsoft-store-msix",
+ UpdateSource: "microsoft-store"));
+ }
+}
diff --git a/src/VaultSync.UI/Services/RecoveryReportExporter.cs b/src/VaultSync.UI/Services/RecoveryReportExporter.cs
index 35149386..feec45e6 100644
--- a/src/VaultSync.UI/Services/RecoveryReportExporter.cs
+++ b/src/VaultSync.UI/Services/RecoveryReportExporter.cs
@@ -5,6 +5,7 @@
using System.Linq;
using System.Text;
using System.Security.Cryptography;
+using VaultSync.Core.Services;
namespace VaultSync.UI.Services;
@@ -49,7 +50,8 @@ internal sealed record RecoveryReportSnapshot(
int PassedDrillCount = 0,
int ProtectedPointCount = 0,
string AppVersion = "unknown",
- string SourceIdentity = "local");
+ string SourceIdentity = "local",
+ BuildInformation? Build = null);
internal sealed record RecoveryReportLabels(
string Title,
@@ -101,6 +103,16 @@ public static string BuildMarkdown(RecoveryReportSnapshot snapshot, RecoveryRepo
.AppendLine(snapshot.GeneratedAt.ToLocalTime().ToString("F", CultureInfo.CurrentCulture));
builder.Append("**Application:** VaultSync ").AppendLine(snapshot.AppVersion);
builder.Append("**Source identity:** ").AppendLine(snapshot.SourceIdentity);
+ if (snapshot.Build is { } build)
+ {
+ builder.Append("**Release channel:** ").AppendLine(build.ReleaseChannel);
+ builder.Append("**Source commit:** ").AppendLine(build.SourceCommit);
+ builder.Append("**Runtime:** ").Append(build.Runtime).Append(" (").Append(build.RuntimeIdentifier).AppendLine(")");
+ builder.Append("**Architecture:** ").AppendLine(build.Architecture);
+ builder.Append("**Package:** ").Append(build.PackageKind).Append("; updates: ").AppendLine(build.UpdateSource);
+ builder.Append("**Official build:** ").AppendLine(build.OfficialBuild ? "yes" : "no");
+ builder.Append("**Signature:** ").AppendLine(build.SignatureStatus);
+ }
builder.AppendLine();
builder.Append("## ").AppendLine(labels.Overview);
diff --git a/src/VaultSync.UI/Services/SupportBundleService.cs b/src/VaultSync.UI/Services/SupportBundleService.cs
index 3101ddb8..4592040c 100644
--- a/src/VaultSync.UI/Services/SupportBundleService.cs
+++ b/src/VaultSync.UI/Services/SupportBundleService.cs
@@ -3,7 +3,6 @@
using System.IO;
using System.IO.Compression;
using System.Linq;
-using System.Reflection;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using VaultSync.Core.Config;
@@ -43,6 +42,7 @@ public static SupportBundleExportResult Export(IAppConfigStore? configStore = nu
string reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
{
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
});
File.WriteAllText(Path.Combine(stagingRoot, "support-report.json"), reportJson);
@@ -83,19 +83,7 @@ private static object BuildBundleReport(AppConfig config, DateTimeOffset timesta
return new
{
generatedUtc = timestamp,
- app = new
- {
- assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown",
- runtime = Environment.Version.ToString(),
- os = Environment.OSVersion.ToString(),
- processArch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
- osArch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(),
- distributionChannel = DistributionChannelService.Current.Channel.ToString(),
- distributionDetectionSource = DistributionChannelService.Current.DetectionSource,
- isPackaged = DistributionChannelService.Current.IsPackaged,
- packageFamilyName = DistributionChannelService.Current.PackageFamilyName,
- packageFullName = DistributionChannelService.Current.PackageFullName
- },
+ app = AppBuildInformationService.Current,
redactedConfig = BuildRedactedConfig(config),
localMetadata,
destinationMetadata
diff --git a/src/VaultSync.UI/ViewModels/RecoveryViewModel.cs b/src/VaultSync.UI/ViewModels/RecoveryViewModel.cs
index c70b9d7b..4facca95 100644
--- a/src/VaultSync.UI/ViewModels/RecoveryViewModel.cs
+++ b/src/VaultSync.UI/ViewModels/RecoveryViewModel.cs
@@ -427,10 +427,9 @@ private RecoveryReportSnapshot BuildReportSnapshot() =>
DrilledProjectCount,
PassedDrillCount,
ProtectedPointCount,
- Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion
- ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
- ?? "unknown",
- BuildSourceIdentity());
+ AppBuildInformationService.Current.Version,
+ BuildSourceIdentity(),
+ AppBuildInformationService.Current);
private static string BuildSourceIdentity()
{
diff --git a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
index d8cb65f6..00c043ca 100644
--- a/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
+++ b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs
@@ -143,6 +143,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
private string _updateDiagnosticsText = string.Empty;
private string _startupDiagnosticsText = string.Empty;
private string _checkpointResumeDiagnosticsText = string.Empty;
+ private string _buildInformationCopyStatus = string.Empty;
private string _retentionSimulationStatus = string.Empty;
private string _retentionSimulationSummary = string.Empty;
private string _retentionSimulationDetails = string.Empty;
@@ -677,6 +678,8 @@ public SettingsViewModel(
ExportLogConsoleCommand = new RelayCommand(_ => ExportLogConsole());
ExportSupportBundleCommand = new RelayCommand(_ => ExportSupportBundle());
ImportSupportBundleCommand = new RelayCommand(_ => ImportSupportBundle());
+ CopyBuildInformationCommand = new RelayCommand(_ =>
+ _ = DetachedTask.RunAsync(CopyBuildInformationAsync, nameof(CopyBuildInformationAsync)));
CheckUpdatesNowCommand = new RelayCommand(_ => CheckUpdatesNow());
OpenMicrosoftStoreCommand = new RelayCommand(_ => OpenMicrosoftStoreListing());
_scanBackupIndexRepairPlanCommand = new RelayCommand(_ => ScanBackupIndexRepairPlan(), _ => !IsBackupIndexRepairBusy);
@@ -2355,6 +2358,20 @@ public string CheckpointResumeDiagnosticsText
private set => SetField(ref _checkpointResumeDiagnosticsText, value);
}
+ public string BuildInformationText => AppBuildInformationService.Current.ToDisplayText();
+
+ public string BuildInformationCopyStatus
+ {
+ get => _buildInformationCopyStatus;
+ private set
+ {
+ if (SetField(ref _buildInformationCopyStatus, value))
+ OnPropertyChanged(nameof(HasBuildInformationCopyStatus));
+ }
+ }
+
+ public bool HasBuildInformationCopyStatus => !string.IsNullOrWhiteSpace(BuildInformationCopyStatus);
+
public bool HasUpdateCheckError => !string.IsNullOrWhiteSpace(_updateCheckErrorText);
public IReadOnlyList LanguageOptions => _localizationService.SupportedLanguages;
@@ -2823,6 +2840,7 @@ private static string PrependPathEntry(string existing, string entry)
public ICommand ExportLogConsoleCommand { get; }
public ICommand ExportSupportBundleCommand { get; }
public ICommand ImportSupportBundleCommand { get; }
+ public ICommand CopyBuildInformationCommand { get; }
public ICommand CheckUpdatesNowCommand { get; }
public ICommand OpenMicrosoftStoreCommand { get; }
public ICommand ScanBackupIndexRepairPlanCommand => _scanBackupIndexRepairPlanCommand!;
@@ -3802,6 +3820,14 @@ private void ExportSupportBundle()
TryOpenContainingFolder(result.ZipPath);
}
+ private async Task CopyBuildInformationAsync()
+ {
+ bool copied = await ClipboardHelper.TryCopyAsync(AppBuildInformationService.Current.ToJson(indented: true));
+ BuildInformationCopyStatus = copied
+ ? L("Settings.Advanced.BuildInformationCopied", "Build information copied.")
+ : L("Settings.Advanced.BuildInformationCopyFailed", "Could not copy build information.");
+ }
+
private static void TryOpenContainingFolder(string? artifactPath)
{
try
diff --git a/src/VaultSync.UI/Views/SettingsView.axaml b/src/VaultSync.UI/Views/SettingsView.axaml
index 32a63e71..d8c64296 100644
--- a/src/VaultSync.UI/Views/SettingsView.axaml
+++ b/src/VaultSync.UI/Views/SettingsView.axaml
@@ -1888,6 +1888,40 @@
FontSize="12" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/VaultSync.Core.Tests/BuildInformationServiceTests.cs b/tests/VaultSync.Core.Tests/BuildInformationServiceTests.cs
new file mode 100644
index 00000000..3a9d319d
--- /dev/null
+++ b/tests/VaultSync.Core.Tests/BuildInformationServiceTests.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Text.Json;
+using VaultSync.Core.Services;
+using Xunit;
+
+namespace VaultSync.Core.Tests;
+
+public sealed class BuildInformationServiceTests
+{
+ [Theory]
+ [InlineData("windows-unpackaged", "github")]
+ [InlineData("windows-portable", "github")]
+ [InlineData("microsoft-store-msix", "microsoft-store")]
+ [InlineData("macos-app", "github")]
+ [InlineData("linux-appimage", "github")]
+ [InlineData("development", "none")]
+ public void Create_distinguishes_package_and_update_sources(string packageKind, string updateSource)
+ {
+ BuildInformation information = BuildInformationService.Create(
+ typeof(BuildInformationServiceTests).Assembly,
+ new BuildInformationOverrides(PackageKind: packageKind, UpdateSource: updateSource));
+
+ Assert.Equal(packageKind, information.PackageKind);
+ Assert.Equal(updateSource, information.UpdateSource);
+ Assert.False(information.OfficialBuild);
+ }
+
+ [Fact]
+ public void Create_requires_complete_stamped_metadata_before_marking_build_official()
+ {
+ Assembly complete = BuildAssembly(
+ informationalVersion: "1.8.7+0123456789abcdef",
+ ("VaultSyncReleaseChannel", "stable"),
+ ("VaultSyncPackageKind", "windows-installer"),
+ ("VaultSyncUpdateSource", "github"),
+ ("VaultSyncOfficialBuild", "true"),
+ ("VaultSyncSourceCommit", "0123456789abcdef"));
+ Assembly incomplete = BuildAssembly(
+ informationalVersion: "1.8.7",
+ ("VaultSyncReleaseChannel", "stable"),
+ ("VaultSyncPackageKind", "windows-installer"),
+ ("VaultSyncOfficialBuild", "true"));
+
+ Assert.True(BuildInformationService.Create(complete).OfficialBuild);
+ Assert.False(BuildInformationService.Create(incomplete).OfficialBuild);
+ Assert.Equal(BuildInformation.Unknown, BuildInformationService.Create(incomplete).SourceCommit);
+ }
+
+ [Fact]
+ public void Json_uses_the_canonical_machine_readable_field_names()
+ {
+ BuildInformation information = BuildInformationService.Create(typeof(BuildInformationServiceTests).Assembly);
+ using JsonDocument document = JsonDocument.Parse(information.ToJson());
+
+ JsonElement root = document.RootElement;
+ Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
+ Assert.True(root.TryGetProperty("sourceCommit", out _));
+ Assert.True(root.TryGetProperty("packageKind", out _));
+ Assert.True(root.TryGetProperty("officialBuild", out _));
+ Assert.False(root.TryGetProperty("SourceCommit", out _));
+ }
+
+ private static Assembly BuildAssembly(string informationalVersion, params (string Key, string Value)[] metadata)
+ {
+ AssemblyBuilder assembly = AssemblyBuilder.DefineDynamicAssembly(
+ new AssemblyName($"VaultSync.BuildInformation.Tests.{Guid.NewGuid():N}") { Version = new Version(1, 8, 7) },
+ AssemblyBuilderAccess.Run);
+ ConstructorInfo informationalConstructor = typeof(AssemblyInformationalVersionAttribute)
+ .GetConstructor([typeof(string)])!;
+ assembly.SetCustomAttribute(new CustomAttributeBuilder(informationalConstructor, [informationalVersion]));
+
+ ConstructorInfo metadataConstructor = typeof(AssemblyMetadataAttribute)
+ .GetConstructor([typeof(string), typeof(string)])!;
+ foreach ((string key, string value) in metadata)
+ assembly.SetCustomAttribute(new CustomAttributeBuilder(metadataConstructor, [key, value]));
+
+ return assembly;
+ }
+}
diff --git a/tests/VaultSync.Core.Tests/RecoveryReportExporterTests.cs b/tests/VaultSync.Core.Tests/RecoveryReportExporterTests.cs
index 1ac1b4ba..9147c33e 100644
--- a/tests/VaultSync.Core.Tests/RecoveryReportExporterTests.cs
+++ b/tests/VaultSync.Core.Tests/RecoveryReportExporterTests.cs
@@ -3,6 +3,7 @@
using System;
using System.IO;
using VaultSync.Core.Tests.TestSupport;
+using VaultSync.Core.Services;
using VaultSync.UI.Services;
using Xunit;
@@ -106,6 +107,22 @@ public void BuildMarkdown_IncludesStableRecoveryProofEvidence()
Assert.Contains("SHA-256", report);
}
+ [Fact]
+ public void BuildMarkdown_IncludesCanonicalBuildIdentity()
+ {
+ BuildInformation build = new(
+ 1, "VaultSync", "1.8.7", "stable", "abcdef123456", ".NET 10", "win-x64",
+ "x64", "Windows", "windows-installer", "github", true, "unsigned");
+ RecoveryReportSnapshot snapshot = CreateSnapshot() with { AppVersion = build.Version, Build = build };
+
+ string report = RecoveryReportExporter.BuildMarkdown(snapshot, CreateLabels());
+
+ Assert.Contains("**Source commit:** abcdef123456", report);
+ Assert.Contains("**Package:** windows-installer; updates: github", report);
+ Assert.Contains("**Official build:** yes", report);
+ Assert.Contains("**Signature:** unsigned", report);
+ }
+
private static RecoveryReportSnapshot CreateSnapshot() =>
new(
new DateTimeOffset(2026, 6, 23, 12, 0, 0, TimeSpan.Zero),
From 7af506a3f49fae6299909ede85ca7f4776df04b0 Mon Sep 17 00:00:00 2001
From: Flavio Giacchetti <38221678+ATAC-Helicopter@users.noreply.github.com>
Date: Mon, 17 Aug 2026 01:21:40 +0200
Subject: [PATCH 54/54] Generate and attest release SBOMs
---
.github/workflows/release-assets.yml | 143 ++++++++++++++++
CHANGELOG.md | 1 +
README.md | 4 +
ROADMAP.md | 5 +
docs/RELEASE_1.8.7.md | 10 +-
docs/RELEASING.md | 46 ++++++
docs/WHATS_NEW.md | 1 +
scripts/release_sbom.py | 239 +++++++++++++++++++++++++++
tests/scripts/test_release_sbom.py | 102 ++++++++++++
9 files changed, 548 insertions(+), 3 deletions(-)
create mode 100644 scripts/release_sbom.py
create mode 100644 tests/scripts/test_release_sbom.py
diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml
index d738af51..ec7537e5 100644
--- a/.github/workflows/release-assets.yml
+++ b/.github/workflows/release-assets.yml
@@ -715,3 +715,146 @@ jobs:
name: canonical-release-manifest
if-no-files-found: error
path: release-assets/vaultsync-release-manifest.json
+
+ supply-chain-proof:
+ name: Generate SBOMs and attest release packages
+ needs: [release-manifest]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Download direct-release artifacts
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
+ with:
+ pattern: "*-release-assets"
+ path: release-assets
+ merge-multiple: true
+
+ - name: Download canonical release manifest
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
+ with:
+ name: canonical-release-manifest
+ path: release-assets
+
+ - name: Resolve dependency graph
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p supply-chain/project-assets
+ for rid in win-x64 osx-arm64 osx-x64 linux-x64 linux-arm64; do
+ framework=net10.0
+ if [[ "$rid" == win-x64 ]]; then
+ framework=net10.0-windows10.0.19041.0
+ fi
+ dotnet restore src/VaultSync.UI/VaultSync.UI.csproj \
+ -p:EnableWindowsTargeting=true \
+ -p:TargetFramework="$framework" \
+ -r "$rid"
+ cp src/VaultSync.UI/obj/project.assets.json "supply-chain/project-assets/$rid.json"
+ done
+
+ - name: Generate and validate SPDX SBOMs
+ shell: bash
+ run: |
+ set -euo pipefail
+ created="$(git show -s --format=%cI "$GITHUB_SHA")"
+ python3 scripts/release_sbom.py generate \
+ --manifest release-assets/vaultsync-release-manifest.json \
+ --project-assets supply-chain/project-assets \
+ --output supply-chain/sboms \
+ --created "$created"
+ python3 scripts/release_sbom.py validate \
+ --manifest release-assets/vaultsync-release-manifest.json \
+ --sbom-root supply-chain/sboms
+ sed 's# \*# *release-assets/#' \
+ supply-chain/sboms/vaultsync-release-subjects.sha256 \
+ > supply-chain/attestation-subjects.sha256
+
+ - name: Attest release-package provenance
+ id: provenance
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-checksums: supply-chain/attestation-subjects.sha256
+
+ - name: Attest Windows installer SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-Setup-${{ inputs.target_version }}.exe
+ sbom-path: supply-chain/sboms/VaultSync-Setup-${{ inputs.target_version }}.exe.spdx.json
+
+ - name: Attest macOS Apple silicon SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-macos-apple-silicon.dmg
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-macos-apple-silicon.dmg.spdx.json
+
+ - name: Attest macOS Intel SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-macos-intel.dmg
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-macos-intel.dmg.spdx.json
+
+ - name: Attest Linux x64 archive SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-linux-x64.tar.gz
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-linux-x64.tar.gz.spdx.json
+
+ - name: Attest Linux x64 Debian SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-linux-x64.deb
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-linux-x64.deb.spdx.json
+
+ - name: Attest Linux x64 AppImage SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-linux-x64.AppImage
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-linux-x64.AppImage.spdx.json
+
+ - name: Attest Linux arm64 archive SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-linux-arm64.tar.gz
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-linux-arm64.tar.gz.spdx.json
+
+ - name: Attest Linux arm64 Debian SBOM
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: release-assets/VaultSync-${{ inputs.target_version }}-linux-arm64.deb
+ sbom-path: supply-chain/sboms/VaultSync-${{ inputs.target_version }}-linux-arm64.deb.spdx.json
+
+ - name: Verify online and offline release-candidate provenance
+ if: ${{ inputs.release_candidate }}
+ shell: bash
+ env:
+ PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }}
+ TARGET_VERSION: ${{ inputs.target_version }}
+ run: |
+ set -euo pipefail
+ subject="release-assets/VaultSync-Setup-$TARGET_VERSION.exe"
+ gh attestation verify "$subject" --repo "$GITHUB_REPOSITORY"
+ gh attestation trusted-root > supply-chain/trusted-root.jsonl
+ gh attestation verify "$subject" \
+ --repo "$GITHUB_REPOSITORY" \
+ --bundle "$PROVENANCE_BUNDLE" \
+ --custom-trusted-root supply-chain/trusted-root.jsonl
+
+ - name: Upload release SBOMs
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-supply-chain-proof
+ if-no-files-found: error
+ path: |
+ supply-chain/sboms
+ supply-chain/trusted-root.jsonl
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76de57c5..8b315427 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## [1.8.7] - Unreleased
### Added
- [VS-1871] Added one conservative build-identity record across Settings, startup diagnostics, support and recovery exports, plus `vaultsync --version --json`; version, channel, commit, runtime, architecture, package, update source, official status, and signature status now come from the same contract.
+- [VS-1873] Added per-package SPDX 2.3 SBOM generation from the canonical release manifest and RID-specific NuGet graph, pinned GitHub provenance/SBOM attestations for final package bytes, and online plus offline release-candidate verification.
- [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/README.md b/README.md
index f5dcc0cb..95e3726b 100644
--- a/README.md
+++ b/README.md
@@ -230,6 +230,10 @@ Direct-download desktop installers are intentionally unsigned because paid platf
Download only from the official `ATAC-Helicopter/VaultSync` release page and compare the asset's published SHA-256 digest before bypassing an operating-system warning. VaultSync's updater also rejects installer and patch downloads whose trusted GitHub digest or exact size is missing or mismatched.
+Release packages also publish SPDX 2.3 SBOMs and GitHub artifact attestations
+for the final downloadable bytes. Online and offline verification commands are
+documented in [the release guide](docs/RELEASING.md#sbom-and-provenance-verification).
+
Windows SmartScreen instructions
diff --git a/ROADMAP.md b/ROADMAP.md
index 00108728..4db4edca 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -421,6 +421,11 @@ after the planned minor train is complete and use explicit beta qualification.
- 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.
+ - Implemented 2026-08-17: final manifest-listed packages receive validated SPDX
+ 2.3 documents tied to their exact SHA-256 and RID-specific dependency graph.
+ A commit-pinned GitHub action attests final package provenance and each SBOM;
+ candidate automation exercises both API-backed and downloaded-bundle trust.
+ One release-candidate workflow run remains required before completion.
- [ ] `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
diff --git a/docs/RELEASE_1.8.7.md b/docs/RELEASE_1.8.7.md
index 04647670..a2fef8a1 100644
--- a/docs/RELEASE_1.8.7.md
+++ b/docs/RELEASE_1.8.7.md
@@ -124,6 +124,11 @@ complete and stable enough for broader qualification.
package, update source, official status, and honest signature state; missing
facts remain `unknown` and incomplete builds cannot claim official status
(`VS-1871`).
+- Every final direct package now receives a validated SPDX 2.3 SBOM tied to its
+ canonical-manifest SHA-256 and platform-specific resolved dependency graph.
+ GitHub signs provenance for the final package bytes and an SBOM attestation
+ for each package; release-candidate automation verifies one package both
+ online and from a downloaded bundle plus trusted-root snapshot (`VS-1873`).
These changes are not shipped until the release work reaches `Stable`.
Dependabot can therefore continue to report the old default-branch runtime
@@ -134,15 +139,14 @@ branch package.
1. Complete the two-machine, disconnect, clock-skew, and NAS/SMB qualification
matrix for versioned metadata merging.
-2. Exercise two-machine, disconnect, clock-skew, and representative NAS/SMB
- behavior before enabling supported concurrent-machine workflows.
+2. Run the release-candidate supply-chain job to qualify its online and offline
+ attestation checks against real final packages.
3. Reduce codebase duplication and oversized orchestration through shared,
regression-tested primitives without combining genuinely different platform
behavior.
### Still planned
-- per-platform SBOMs and supported build provenance;
- checksummed Recovery Evidence Packages;
- allowlisted, reviewable support bundles;
- synchronized public release metadata;
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index c70017fd..1246f7b5 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -154,6 +154,50 @@ if (-not $expected -or $actual -cne $expected) { throw "SHA-256 verification fai
Before using a checksum, confirm the manifest itself came from the matching tag
on the official `ATAC-Helicopter/VaultSync` GitHub Releases page.
+### SBOM and provenance verification
+
+The `release-supply-chain-proof` workflow artifact contains one SPDX 2.3 JSON
+document per self-contained direct package, an exact artifact-to-SBOM index,
+and the package checksum list. Validate the complete set against the canonical
+manifest without network access:
+
+```bash
+python3 scripts/release_sbom.py validate \
+ --manifest release-assets/vaultsync-release-manifest.json \
+ --sbom-root release-supply-chain-proof/sboms
+```
+
+GitHub attestations bind the final installer, DMG, tarball, Debian package, or
+AppImage bytes—not an intermediate publish directory—to this repository,
+workflow run, triggering event, and commit. Verify a downloaded package online:
+
+```bash
+gh attestation verify VaultSync-Setup-1.8.7.exe \
+ --repo ATAC-Helicopter/VaultSync
+```
+
+For later offline verification, prepare the bundle and current public trusted
+roots while connected:
+
+```bash
+gh attestation download VaultSync-Setup-1.8.7.exe \
+ --repo ATAC-Helicopter/VaultSync
+gh attestation trusted-root > trusted_root.jsonl
+```
+
+Move the package, downloaded `sha256:*.jsonl` bundle, trusted root, and GitHub
+CLI to the offline machine, then run:
+
+```bash
+gh attestation verify VaultSync-Setup-1.8.7.exe \
+ --repo ATAC-Helicopter/VaultSync \
+ --bundle 'sha256:DIGEST.jsonl' \
+ --custom-trusted-root trusted_root.jsonl
+```
+
+Refresh trusted roots before each archival transfer when possible; an offline
+copy cannot reveal trust-root revocations that occurred after it was captured.
+
## 5) Release Checklist
- Run the release gate before publishing:
```powershell
@@ -170,6 +214,8 @@ on the official `ATAC-Helicopter/VaultSync` GitHub Releases page.
- 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
+- one validated SPDX 2.3 SBOM per self-contained package, with final-byte
+ provenance and SBOM attestations plus candidate online/offline verification
- 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/WHATS_NEW.md b/docs/WHATS_NEW.md
index ef8ea8e4..5cab991e 100644
--- a/docs/WHATS_NEW.md
+++ b/docs/WHATS_NEW.md
@@ -72,6 +72,7 @@ VaultSync `1.8.5` is the Recovery Confidence update. It answers a direct questio
### Update integrity and local privacy
- Installer downloads, patch manifests, and patch archives must match the exact size and SHA-256 digest published by GitHub before VaultSync will use them.
+- Release packages include SPDX 2.3 SBOMs and GitHub provenance/SBOM attestations for the final downloadable bytes, with documented online and offline verification.
- Update URLs are restricted to the official VaultSync GitHub release path, and missing or inconsistent integrity metadata fails closed to the release page.
- Patch extraction rejects traversing, colliding, linked, oversized, or non-portable paths before replacing application files.
- On Unix-like systems, VaultSync restricts configuration, backups of configuration, and application-data roots to the current user.
diff --git a/scripts/release_sbom.py b/scripts/release_sbom.py
new file mode 100644
index 00000000..d338193b
--- /dev/null
+++ b/scripts/release_sbom.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""Generate and validate SPDX 2.3 SBOMs bound to canonical release assets."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+SPDX_VERSION = "SPDX-2.3"
+DATA_LICENSE = "CC0-1.0"
+SELF_CONTAINED_KINDS = {"installer", "store-upload", "disk-image", "archive", "debian-package", "appimage"}
+SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+
+
+def load_json(path: Path) -> dict:
+ with path.open("r", encoding="utf-8") as stream:
+ value = json.load(stream)
+ if not isinstance(value, dict):
+ raise ValueError(f"Expected a JSON object: {path}")
+ return value
+
+
+def safe_id(value: str) -> str:
+ return re.sub(r"[^A-Za-z0-9.-]", "-", value).strip("-") or "unknown"
+
+
+def load_nuget_packages(assets_path: Path | None, runtime_identifier: str) -> list[tuple[str, str]]:
+ if assets_path is None:
+ return []
+ if assets_path.is_dir():
+ assets_path = assets_path / f"{runtime_identifier}.json"
+ assets = load_json(assets_path)
+ libraries = assets.get("libraries", {})
+ target_keys: set[str] | None = None
+ for target_name, target in assets.get("targets", {}).items():
+ if target_name.endswith(f"/{runtime_identifier}") and isinstance(target, dict):
+ target_keys = set(target)
+ break
+ packages: set[tuple[str, str]] = set()
+ for key, value in libraries.items():
+ if not isinstance(value, dict) or value.get("type") != "package" or "/" not in key:
+ continue
+ if target_keys is not None and key not in target_keys:
+ continue
+ name, version = key.rsplit("/", 1)
+ packages.add((name, version))
+ return sorted(packages, key=lambda item: (item[0].casefold(), item[1]))
+
+
+def dependency_package(name: str, version: str) -> dict:
+ identity = hashlib.sha256(f"{name}@{version}".encode()).hexdigest()[:16]
+ return {
+ "SPDXID": f"SPDXRef-NuGet-{identity}",
+ "name": name,
+ "versionInfo": version,
+ "downloadLocation": f"https://www.nuget.org/packages/{name}/{version}",
+ "filesAnalyzed": False,
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ "externalRefs": [{
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": f"pkg:nuget/{name}@{version}",
+ }],
+ }
+
+
+def artifact_package(asset: dict, version: str) -> dict:
+ return {
+ "SPDXID": "SPDXRef-ReleaseArtifact",
+ "name": asset["name"],
+ "versionInfo": version,
+ "downloadLocation": asset["downloadUrl"],
+ "filesAnalyzed": False,
+ "packageFileName": asset["name"],
+ "primaryPackagePurpose": "APPLICATION",
+ "checksums": [{"algorithm": "SHA256", "checksumValue": asset["sha256"]}],
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "Copyright (c) 2025-2026 Flavio Giacchetti",
+ "externalRefs": [{
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": (
+ f"pkg:generic/vaultsync@{version}?os={asset['platform']}&arch={asset['architecture']}"
+ f"&packaging={asset['packageKind']}"
+ ),
+ }],
+ }
+
+
+def build_document(manifest: dict, asset: dict, dependencies: list[tuple[str, str]], created: str) -> dict:
+ release = manifest["release"]
+ artifact = artifact_package(asset, release["version"])
+ packages = [artifact, *(dependency_package(name, version) for name, version in dependencies)]
+ relationships = [{
+ "spdxElementId": "SPDXRef-DOCUMENT",
+ "relationshipType": "DESCRIBES",
+ "relatedSpdxElement": artifact["SPDXID"],
+ }]
+ relationships.extend({
+ "spdxElementId": artifact["SPDXID"],
+ "relationshipType": "DEPENDS_ON",
+ "relatedSpdxElement": package["SPDXID"],
+ } for package in packages[1:])
+ namespace_seed = f"{release['repository']}:{release['version']}:{asset['name']}:{asset['sha256']}"
+ namespace_id = hashlib.sha256(namespace_seed.encode()).hexdigest()
+ return {
+ "spdxVersion": SPDX_VERSION,
+ "dataLicense": DATA_LICENSE,
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": f"VaultSync {release['version']} - {asset['name']}",
+ "documentNamespace": f"https://github.com/{release['repository']}/sbom/{namespace_id}",
+ "creationInfo": {
+ "created": created,
+ "creators": ["Tool: VaultSync release_sbom.py", "Organization: VaultSync"],
+ },
+ "documentDescribes": [artifact["SPDXID"]],
+ "packages": packages,
+ "relationships": relationships,
+ "annotations": [{
+ "annotationDate": created,
+ "annotationType": "OTHER",
+ "annotator": "Tool: VaultSync release_sbom.py",
+ "comment": (
+ f"Canonical release manifest commit={release['commit']} channel={release['channel']} "
+ f"platform={asset['platform']} architecture={asset['architecture']} "
+ f"packageKind={asset['packageKind']}"
+ ),
+ }],
+ }
+
+
+def validate_document(document: dict, asset: dict | None = None) -> None:
+ if document.get("spdxVersion") != SPDX_VERSION or document.get("dataLicense") != DATA_LICENSE:
+ raise ValueError("SBOM must declare SPDX-2.3 and CC0-1.0")
+ if document.get("SPDXID") != "SPDXRef-DOCUMENT":
+ raise ValueError("SBOM document SPDXID is invalid")
+ packages = document.get("packages")
+ if not isinstance(packages, list) or not packages:
+ raise ValueError("SBOM contains no packages")
+ ids = [package.get("SPDXID") for package in packages if isinstance(package, dict)]
+ if len(ids) != len(packages) or len(set(ids)) != len(ids):
+ raise ValueError("SBOM package identifiers are missing or duplicated")
+ known_ids = {"SPDXRef-DOCUMENT", *ids}
+ for relationship in document.get("relationships", []):
+ if relationship.get("spdxElementId") not in known_ids or relationship.get("relatedSpdxElement") not in known_ids:
+ raise ValueError("SBOM relationship refers to an unknown SPDX identifier")
+ artifact = next((package for package in packages if package.get("SPDXID") == "SPDXRef-ReleaseArtifact"), None)
+ if artifact is None:
+ raise ValueError("SBOM does not identify its release artifact")
+ if asset is not None:
+ checksums = artifact.get("checksums", [])
+ expected = {"algorithm": "SHA256", "checksumValue": asset["sha256"]}
+ if artifact.get("name") != asset["name"] or expected not in checksums:
+ raise ValueError(f"SBOM is not bound to release asset {asset['name']}")
+
+
+def generate(manifest_path: Path, output: Path, assets_path: Path | None, created: str) -> None:
+ manifest = load_json(manifest_path)
+ output.mkdir(parents=True, exist_ok=True)
+ subjects: list[str] = []
+ index: list[dict] = []
+ for asset in manifest.get("assets", []):
+ if asset.get("packageKind") not in SELF_CONTAINED_KINDS:
+ continue
+ runtime_identifier = {
+ ("windows", "x64"): "win-x64",
+ ("macos", "arm64"): "osx-arm64",
+ ("macos", "x64"): "osx-x64",
+ ("linux", "x64"): "linux-x64",
+ ("linux", "arm64"): "linux-arm64",
+ }[(asset["platform"], asset["architecture"])]
+ dependencies = load_nuget_packages(assets_path, runtime_identifier)
+ document = build_document(manifest, asset, dependencies, created)
+ validate_document(document, asset)
+ name = f"{asset['name']}.spdx.json"
+ (output / name).write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ subjects.append(f"{asset['sha256']} *{asset['name']}")
+ index.append({"artifact": asset["name"], "sha256": asset["sha256"], "sbom": name})
+ if not index:
+ raise ValueError("Canonical manifest contains no self-contained release packages")
+ (output / "vaultsync-release-subjects.sha256").write_text("\n".join(subjects) + "\n", encoding="utf-8")
+ (output / "vaultsync-release-sbom-index.json").write_text(
+ json.dumps({"schemaVersion": 1, "release": manifest["release"], "sboms": index}, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+def validate(manifest_path: Path, sbom_root: Path) -> None:
+ manifest = load_json(manifest_path)
+ expected = {asset["name"]: asset for asset in manifest.get("assets", []) if asset.get("packageKind") in SELF_CONTAINED_KINDS}
+ index = load_json(sbom_root / "vaultsync-release-sbom-index.json")
+ indexed = {entry["artifact"]: entry for entry in index.get("sboms", [])}
+ if set(indexed) != set(expected):
+ raise ValueError("SBOM index does not exactly cover self-contained release assets")
+ for name, asset in expected.items():
+ document = load_json(sbom_root / indexed[name]["sbom"])
+ validate_document(document, asset)
+ checksum_lines = (sbom_root / "vaultsync-release-subjects.sha256").read_text(encoding="utf-8").splitlines()
+ if len(checksum_lines) != len(expected) or any(not SHA256_PATTERN.match(line.split(" ", 1)[0]) for line in checksum_lines):
+ raise ValueError("Release subject checksum file is invalid")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ sub = parser.add_subparsers(dest="command", required=True)
+ generate_parser = sub.add_parser("generate")
+ generate_parser.add_argument("--manifest", type=Path, required=True)
+ generate_parser.add_argument("--output", type=Path, required=True)
+ generate_parser.add_argument("--project-assets", type=Path)
+ generate_parser.add_argument("--created", default=datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"))
+ validate_parser = sub.add_parser("validate")
+ validate_parser.add_argument("--manifest", type=Path, required=True)
+ validate_parser.add_argument("--sbom-root", type=Path, required=True)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ if args.command == "generate":
+ generate(args.manifest, args.output, args.project_assets, args.created)
+ else:
+ validate(args.manifest, args.sbom_root)
+ return 0
+ except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
+ print(f"release SBOM error: {error}")
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/scripts/test_release_sbom.py b/tests/scripts/test_release_sbom.py
new file mode 100644
index 00000000..c2ccd3da
--- /dev/null
+++ b/tests/scripts/test_release_sbom.py
@@ -0,0 +1,102 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts import release_sbom
+
+
+class ReleaseSbomTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.root = Path(self.temp.name)
+ self.manifest_path = self.root / "vaultsync-release-manifest.json"
+ self.assets_path = self.root / "project.assets.json"
+ self.output = self.root / "sboms"
+ self.asset = {
+ "name": "VaultSync-1.8.7-linux-x64.tar.gz",
+ "platform": "linux",
+ "architecture": "x64",
+ "packageKind": "archive",
+ "sizeBytes": 42,
+ "sha256": "a" * 64,
+ "downloadUrl": "https://github.com/ATAC-Helicopter/VaultSync/releases/download/v1.8.7/VaultSync-1.8.7-linux-x64.tar.gz",
+ }
+ self.manifest_path.write_text(json.dumps({
+ "schemaVersion": 1,
+ "release": {
+ "version": "1.8.7",
+ "channel": "stable",
+ "tag": "v1.8.7",
+ "commit": "b" * 40,
+ "repository": "ATAC-Helicopter/VaultSync",
+ "compatiblePredecessors": ["1.8.6"],
+ },
+ "assets": [self.asset, {
+ **self.asset,
+ "name": "vaultsync-patch-linux-x64.zip",
+ "packageKind": "patch-archive",
+ "sha256": "c" * 64,
+ }],
+ }), encoding="utf-8")
+ self.assets_path.write_text(json.dumps({
+ "libraries": {
+ "Dapper/2.1.66": {"type": "package"},
+ "VaultSync.Core/1.0.0": {"type": "project"},
+ }
+ }), encoding="utf-8")
+
+ def tearDown(self):
+ self.temp.cleanup()
+
+ def test_generate_creates_one_valid_sbom_per_self_contained_artifact(self):
+ release_sbom.generate(
+ self.manifest_path,
+ self.output,
+ self.assets_path,
+ "2026-08-17T12:00:00Z",
+ )
+
+ release_sbom.validate(self.manifest_path, self.output)
+ index = json.loads((self.output / "vaultsync-release-sbom-index.json").read_text())
+ self.assertEqual([self.asset["name"]], [entry["artifact"] for entry in index["sboms"]])
+ document = json.loads((self.output / index["sboms"][0]["sbom"]).read_text())
+ self.assertEqual("SPDX-2.3", document["spdxVersion"])
+ self.assertTrue(any(package["name"] == "Dapper" for package in document["packages"]))
+ self.assertIn(self.asset["sha256"], (self.output / "vaultsync-release-subjects.sha256").read_text())
+
+ def test_validate_rejects_sbom_not_bound_to_manifest_digest(self):
+ release_sbom.generate(self.manifest_path, self.output, None, "2026-08-17T12:00:00Z")
+ index = json.loads((self.output / "vaultsync-release-sbom-index.json").read_text())
+ path = self.output / index["sboms"][0]["sbom"]
+ document = json.loads(path.read_text())
+ document["packages"][0]["checksums"][0]["checksumValue"] = "d" * 64
+ path.write_text(json.dumps(document), encoding="utf-8")
+
+ with self.assertRaisesRegex(ValueError, "not bound"):
+ release_sbom.validate(self.manifest_path, self.output)
+
+ def test_dependency_loading_selects_the_requested_runtime_graph(self):
+ assets_dir = self.root / "assets"
+ assets_dir.mkdir()
+ (assets_dir / "linux-x64.json").write_text(json.dumps({
+ "libraries": {
+ "Shared/1.0.0": {"type": "package"},
+ "LinuxOnly/2.0.0": {"type": "package"},
+ "OtherRuntime/3.0.0": {"type": "package"},
+ },
+ "targets": {
+ "net10.0/linux-x64": {
+ "Shared/1.0.0": {},
+ "LinuxOnly/2.0.0": {},
+ }
+ },
+ }), encoding="utf-8")
+
+ packages = release_sbom.load_nuget_packages(assets_dir, "linux-x64")
+
+ self.assertEqual([("LinuxOnly", "2.0.0"), ("Shared", "1.0.0")], packages)
+
+
+if __name__ == "__main__":
+ unittest.main()