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/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 422b6a62..ec7537e5 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -283,6 +283,17 @@ 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: > + 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 @@ -435,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: > @@ -443,6 +460,18 @@ 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: > + 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: | @@ -534,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: > @@ -542,6 +577,18 @@ 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: > + 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: @@ -620,3 +667,194 @@ 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 + + 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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c66f3189..8b315427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,37 @@ # 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-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. +- [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. +- [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. +- [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. +- [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. +- [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. +- [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 - [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/DOCUMENTATION.md b/DOCUMENTATION.md index 57fc83d4..76e2fd8a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -29,6 +29,13 @@ 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. +- `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 +49,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 +136,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/Directory.Build.props b/Directory.Build.props index eed86209..dd1efa68 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,25 @@ - - 10.0.9 + + 10.0.11 + true + + development + development + none + false + unknown + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index c4502bf5..d3e18d9d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,9 +6,9 @@ - + - + @@ -38,11 +38,11 @@ - + - + - - + + diff --git a/Localization/strings.ar.json b/Localization/strings.ar.json index 1eb393d5..ebc4151c 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": "اسم المجلد الجديد", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "تحديث سجل البيانات الوصفية", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "استيراد أحدث بيانات الوجهة أثناء الصيانة.", "Settings.Advanced.MetadataConflictsTitle": "تعارضات البيانات الوصفية بين الأجهزة", + "Settings.Advanced.MetadataConflictsAvatarColor": "لون الصورة الرمزية", "Settings.Advanced.MetadataConflictsDescription": "راجع إعدادات المشاريع المستوردة من جهاز آخر قبل أن تستبدل إعدادات الوجهة أو وضع الاستعادة أو التحقق أو العلامات المحلية.", "Settings.Advanced.MetadataConflictsNone": "لا توجد تعارضات بيانات وصفية بين الأجهزة حالياً.", "Settings.Advanced.MetadataConflictsPending": "{0} تعارض بيانات وصفية بين الأجهزة.", @@ -1092,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 9acefff4..b17a6a97 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": "নতুন ফোল্ডারের নাম", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "মেটাডেটা ইতিহাস রিফ্রেশ করুন", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "রক্ষণাবেক্ষণের সময় সর্বশেষ গন্তব্য মেটাডেটা আমদানি করুন।", "Settings.Advanced.MetadataConflictsTitle": "ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব", + "Settings.Advanced.MetadataConflictsAvatarColor": "অবতার রং", "Settings.Advanced.MetadataConflictsDescription": "লোকাল গন্তব্য, রিস্টোর মোড, যাচাইকরণ বা ট্যাগ ওভাররাইট করার আগে অন্য মেশিন থেকে আমদানিকৃত প্রকল্প সেটিংস পর্যালোচনা করুন।", "Settings.Advanced.MetadataConflictsNone": "কোনো ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব নেই।", "Settings.Advanced.MetadataConflictsPending": "{0}টি ক্রস-মেশিন মেটাডেটা দ্বন্দ্ব অপেক্ষমান।", @@ -1092,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 e9dc03da..5d1770c5 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 664f6861..47b8aa68 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", @@ -569,13 +575,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 +1050,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", @@ -1090,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 40bbdb9d..17144907 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 ff864ba5..34348d7f 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 5e910757..5e9b0c11 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": "नये फ़ोल्डर का नाम", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "मेटाडेटा रीफ्रेश", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "नवीनतम मेटाडेटा आयात करें।", "Settings.Advanced.MetadataConflictsTitle": "मेटाडेटा संघर्ष", + "Settings.Advanced.MetadataConflictsAvatarColor": "अवतार रंग", "Settings.Advanced.MetadataConflictsDescription": "स्थानीय सेटिंग्स ओवरराइट करने से पहले आयातित सेटिंग्स की समीक्षा करें।", "Settings.Advanced.MetadataConflictsNone": "कोई मेटाडेटा संघर्ष नहीं।", "Settings.Advanced.MetadataConflictsPending": "{0} मेटाडेटा संघर्ष।", @@ -1092,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 c9ec3489..afd78c97 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 c8655299..817b2358 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 ee5d5b31..82fa4969 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": "新しいフォルダー名", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "メタデータ履歴の更新", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "メンテナンス中に最新の目的地メタデータをインポートしてください。", "Settings.Advanced.MetadataConflictsTitle": "クロスマシン間のメタデータ競合", + "Settings.Advanced.MetadataConflictsAvatarColor": "アバターの色", "Settings.Advanced.MetadataConflictsDescription": "他のマシンからインポートしたプロジェクト設定を、ローカルの目的地や復元モード、検証、タグが上書きされる前に必ず確認してください。", "Settings.Advanced.MetadataConflictsNone": "未処理のクロスマシンメタデータ競合はありません。", "Settings.Advanced.MetadataConflictsPending": "未処理中のクロスマシンメタデータの競合{0}。", @@ -1092,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 1dcab7bf..866f80be 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": "새 폴더 이름", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "메타데이터 기록 새로고침", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "유지보수 실행 중에 최신 목적지 메타데이터를 가져오세요.", "Settings.Advanced.MetadataConflictsTitle": "크로스 머신 메타데이터 충돌", + "Settings.Advanced.MetadataConflictsAvatarColor": "아바타 색상", "Settings.Advanced.MetadataConflictsDescription": "다른 컴퓨터에서 가져온 프로젝트 설정이 로컬 목적지, 복원 모드, 검증, 태그를 덮어쓰기 전에 꼭 검토하세요.", "Settings.Advanced.MetadataConflictsNone": "대기 중인 크로스 머신 메타데이터 충돌도 없습니다.", "Settings.Advanced.MetadataConflictsPending": "{0} 대기 중인 크로스 머신 메타데이터 충돌.", @@ -1092,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 893e5e7c..5389f01b 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", @@ -593,13 +599,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 +1052,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).", @@ -1092,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 998eadd4..34c34d74 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 3b779586..26a1c771 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", @@ -593,13 +599,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 +1052,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.", @@ -1092,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 541f2e51..0cc3bf6f 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": "Новое имя папки", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Обновить метаданные", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Импортировать последние метаданные.", "Settings.Advanced.MetadataConflictsTitle": "Конфликты метаданных", + "Settings.Advanced.MetadataConflictsAvatarColor": "Цвет аватара", "Settings.Advanced.MetadataConflictsDescription": "Проверьте импортированные настройки перед перезаписью локальных.", "Settings.Advanced.MetadataConflictsNone": "Нет конфликтов метаданных.", "Settings.Advanced.MetadataConflictsPending": "{0} конфликтов метаданных.", @@ -1092,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 8a8c2061..17f5d640 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ı", @@ -593,13 +599,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 +1052,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)ı.", @@ -1092,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 6f105590..ad3d393d 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": "Нова назва папки", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "Оновити історію метаданих", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "Імпортуйте останні метадані призначення під час технічного обслуговування.", "Settings.Advanced.MetadataConflictsTitle": "Міжмашинні конфлікти метаданих", + "Settings.Advanced.MetadataConflictsAvatarColor": "Колір аватара", "Settings.Advanced.MetadataConflictsDescription": "Перегляньте налаштування проєкту, імпортовані з іншої машини, перш ніж вони перезапишуть ваш локальний пункт призначення, режим відновлення, верифікацію або теги.", "Settings.Advanced.MetadataConflictsNone": "Немає очікуваних конфліктів між метаданими між машинами.", "Settings.Advanced.MetadataConflictsPending": "{0} очікуваний конфлікт метаданих між крос-машинами.", @@ -1092,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 7bf09327..5b6ed9f7 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", @@ -593,13 +599,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 +1052,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ý.", @@ -1092,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 0e8a28d9..891c04d5 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": "新文件夹名称", @@ -593,13 +599,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 +1052,7 @@ "Settings.Advanced.MaintenanceMetadataRefresh": "刷新元数据", "Settings.Advanced.MaintenanceMetadataRefreshDescription": "导入最新元数据。", "Settings.Advanced.MetadataConflictsTitle": "元数据冲突", + "Settings.Advanced.MetadataConflictsAvatarColor": "头像颜色", "Settings.Advanced.MetadataConflictsDescription": "在覆盖本地设置前检查导入设置。", "Settings.Advanced.MetadataConflictsNone": "没有元数据冲突。", "Settings.Advanced.MetadataConflictsPending": "{0} 个元数据冲突。", @@ -1092,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 2d2b1f58..95e3726b 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 @@ -203,6 +204,11 @@ 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 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. --- @@ -224,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 3bfbb8c9..4db4edca 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 | @@ -315,6 +321,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 +374,234 @@ History carry the same folder identity. ## 1.8.7 — Trust and Portability -**Status:** Planned +**Status:** Active development. Release contracts were approved and implementation +started on 2026-08-12. **Tagline:** *Show the proof.* +**Target:** 2026-08-24 +**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. + +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, +- [x] `VS-1871` `P1` Expose build, channel, commit, runtime, architecture, package, and update-source information. -- [ ] `VS-1872` `P0` Publish artifact checksums and a machine-readable release + - 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. + - 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, + 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. + - 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 + 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. +- [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; + 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. + - 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 + 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. + - 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 and schema-version-3 base/provenance and + 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, + 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 + +- [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 + 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 + 2026-08-12 and automatic head-branch deletion was disabled. +- [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 + 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. + - 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 + 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. +- [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. +- [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. +- [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. +- [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. +- [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. +- [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 + +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 + +- 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/CROSS_MACHINE_SAFETY.md b/docs/CROSS_MACHINE_SAFETY.md new file mode 100644 index 00000000..68bd226e --- /dev/null +++ b/docs/CROSS_MACHINE_SAFETY.md @@ -0,0 +1,178 @@ +# 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 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 | +|---|---| +| 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. + +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. 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 +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. + +## 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. + **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. + **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, 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 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.** +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/HELP.md b/docs/HELP.md index 2bd41ac8..c77dcced 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 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`. ## Destination Modes VaultSync supports two destination modes: diff --git a/docs/README.md b/docs/README.md index 52c1556a..fcf3a140 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,10 @@ 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) +- 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) @@ -29,6 +32,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..a2fef8a1 --- /dev/null +++ b/docs/RELEASE_1.8.7.md @@ -0,0 +1,221 @@ +# 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-08-24 | +| 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. + +## 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 + +- 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. +- 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. 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. 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. +- 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. 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. +- 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`). +- 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`). +- 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`). +- 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 + 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`). +- 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`). +- 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`). +- 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`). +- 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 +until promotion; that is a branch-state difference, not an unaddressed release- +branch package. + +### In progress next + +1. Complete the two-machine, disconnect, clock-skew, and NAS/SMB qualification + matrix for versioned metadata merging. +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 + +- 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. + +## 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..1246f7b5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,13 +2,28 @@ 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) - 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 +50,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 +72,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 +89,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. @@ -92,20 +107,115 @@ 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. + +### 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. + +### 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 - 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 - 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 +- 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/REPOSITORY_FORMATS.md b/docs/REPOSITORY_FORMATS.md new file mode 100644 index 00000000..c68b7407 --- /dev/null +++ b/docs/REPOSITORY_FORMATS.md @@ -0,0 +1,141 @@ +# 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 | +| `/.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 | + +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 `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/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 + 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` 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 + +- 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. +- 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 — Implemented for 1.8.7 + +The coordination database, durable installation identity, protection of all +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. 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, +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 +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/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/docs/UPDATER.md b/docs/UPDATER.md index d4c55ae6..791d11e3 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,19 @@ 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. +- 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. @@ -53,12 +68,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/docs/WHATS_NEW.md b/docs/WHATS_NEW.md index 64c53bb0..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. @@ -260,7 +261,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 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/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/docs/wiki/Metadata-Sync.md b/docs/wiki/Metadata-Sync.md index 1996917f..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,57 @@ 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 + +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. +- 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. + +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. + +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 +in [Repository formats](../REPOSITORY_FORMATS.md). +The writer and merge threat model is in +[Cross-machine safety](../CROSS_MACHINE_SAFETY.md). ![Doctor, metadata-conflict, maintenance, and update controls](../images/Settings_Maintenance.png) 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/release_manifest.py b/scripts/release_manifest.py new file mode 100644 index 00000000..632514f2 --- /dev/null +++ b/scripts/release_manifest.py @@ -0,0 +1,383 @@ +#!/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 = 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): + 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 + return actual + + +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}") + 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}") + return name + + +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: + 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, + ) + 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) + 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) 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 b38e14be..79a9310a 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'." ` @@ -226,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." ` @@ -273,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." ` @@ -297,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/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/scripts/roadmap_sync.py b/scripts/roadmap_sync.py new file mode 100644 index 00000000..c59340b7 --- /dev/null +++ b/scripts/roadmap_sync.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Safely synchronize roadmap ticket contracts into GitHub Project items.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Iterable + + +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" +REPOSITORY_NAME = "VaultSync" +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +@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 + + +@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 + + +def parse_roadmap(text: str) -> dict[str, RoadmapEntry]: + entries: dict[str, RoadmapEntry] = {} + state = _ParserState() + + for raw_line in text.splitlines(): + header = _parse_header(raw_line) + if header is not None: + _flush_entry(entries, state) + state.section = header + continue + + 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 state.ticket_id is None: + continue + _append_continuation(state, raw_line) + + _flush_entry(entries, state) + return entries + + +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 + + +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: + 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]: + 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], 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 + check=True, + shell=False, + text=True, + input=stdin_text, + 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_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", project_number, "--owner", owner, "--format", "json"]) + ) + item_data = json.loads( + run_gh( + [ + "project", + "item-list", + project_number, + "--owner", + owner, + "--limit", + "1000", + "--format", + "json", + ] + ) + ) + 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 or owner != "ATAC-Helicopter": + raise ValueError("--owner must identify the VaultSync repository owner") + return "ATAC-Helicopter" + + +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) + 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: + _apply_issue_change(change, owner) + return + + if change.content_type == "DraftIssue" and change.item_id: + _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: + 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() -> None: + args = parse_args() + 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) + + 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 + + for change in changes: + apply_change(change, args.owner, project_id) + print(f"Descriptions sync complete. updated={len(changes)} indexed={len(index)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/runtime_pack_audit.py b/scripts/runtime_pack_audit.py new file mode 100644 index 00000000..2eac2130 --- /dev/null +++ b/scripts/runtime_pack_audit.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Fail when restore or publish output resolves an unserviced .NET runtime.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tempfile +from pathlib import Path + + +MINIMUM_VERSION_PROPERTY = re.compile( + r"\s*(\d+\.\d+\.\d+)\s*" + r"" +) +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def version_tuple(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".")) + + +def configured_minimum(repo_root: Path) -> str: + 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 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", []) + 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("--runtimeconfig", action="append", required=True) + args = parser.parse_args() + + minimum = configured_minimum(REPOSITORY_ROOT) + errors: list[str] = [] + for path_text in args.runtimeconfig: + path = resolve_runtimeconfig(path_text, REPOSITORY_ROOT) + 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/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/src/VaultSync.CLI/Commands/DestinationCommand.cs b/src/VaultSync.CLI/Commands/DestinationCommand.cs index 08c65037..0f361cbe 100644 --- a/src/VaultSync.CLI/Commands/DestinationCommand.cs +++ b/src/VaultSync.CLI/Commands/DestinationCommand.cs @@ -79,10 +79,8 @@ private static DestinationInfo TestDestination( NetworkCredentialProfile? profile = ResolveCredential(config, dest); DestinationResolution resolution = mountService.PrepareDestination(dest, profile); bool reachable = resolution.IsSuccess; - string message = string.IsNullOrWhiteSpace(resolution.Message) - ? (reachable ? "Reachable" : "Unreachable") - : resolution.Message; - mountService.Cleanup(resolution); + string message = ResolveDestinationMessage(reachable, resolution.Message); + NetworkMountService.Cleanup(resolution); return new DestinationInfo(alias, path, status, reachable, message); } catch (Exception ex) @@ -114,17 +112,29 @@ private static void WriteTable(IEnumerable results, bool test) foreach (DestinationInfo row in results) { - string detail = test - ? (row.Reachable ? "Reachable" : row.Message) - : 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/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/Config/AppConfig.cs b/src/VaultSync.Core/Config/AppConfig.cs index 2b075fe9..6ac54e17 100644 --- a/src/VaultSync.Core/Config/AppConfig.cs +++ b/src/VaultSync.Core/Config/AppConfig.cs @@ -346,6 +346,8 @@ 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 List ProjectMetadataMergeBases { get; set; } = []; public UpdateCheckDiagnostics UpdateDiagnostics { get; set; } = new(); public BackupRepairTelemetry BackupRepairTelemetry { get; set; } = new(); public MetadataConflictTelemetry MetadataConflictTelemetry { get; set; } = new(); @@ -385,18 +387,68 @@ 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; } + 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 { + 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 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 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(); + } + + 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 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 { public string CheckedUtc { get; set; } = string.Empty; diff --git a/src/VaultSync.Core/Config/AppConfigStore.cs b/src/VaultSync.Core/Config/AppConfigStore.cs index 7926b2b3..babf1fb4 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,68 @@ 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 + }, + 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) { if (!File.Exists(path)) 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.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.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/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/src/VaultSync.Core/Services/MetadataStore.cs b/src/VaultSync.Core/Services/MetadataStore.cs index 0f334b22..622c3e57 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 = 3; private const string BackupsTable = "backups"; + private const string ProjectsTable = "projects"; private const string SnapshotsTable = "snapshots"; private readonly string _dbPath; @@ -71,7 +72,12 @@ 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, + 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( @@ -121,6 +127,11 @@ 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, 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;"); @@ -195,14 +206,22 @@ 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, 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, 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, + 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 + END; """, new { @@ -212,10 +231,61 @@ 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), + BaseRevision = Math.Max(0, project.BaseRevision), + project.FieldProvenanceJson, + project.ResolutionJson }); } + 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, 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 + 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, + base_revision = excluded.base_revision, + field_provenance_json = excluded.field_provenance_json, + resolution_json = excluded.resolution_json + 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, + project.FieldProvenanceJson, + project.ResolutionJson, + ExpectedRevision = expectedRevision, + NextRevision = checked(expectedRevision + 1) + }; + + return ExecuteWriteCount(sql, parameters) == 1; + } + public void UpsertSnapshot(MetaSnapshot snapshot) { ExecuteWrite( @@ -338,12 +408,38 @@ 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(); - 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 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, name, @@ -351,9 +447,15 @@ 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}, + {baseRevisionProjection}, + {provenanceProjection}, + {resolutionProjection} FROM projects; - """); + """; + return SafeQuery(c, sql); } public IEnumerable ListProjectRefs() @@ -381,6 +483,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(); @@ -797,6 +908,11 @@ 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 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 e2c00c3a..9c073a56 100644 --- a/src/VaultSync.Core/Services/MetadataSyncService.cs +++ b/src/VaultSync.Core/Services/MetadataSyncService.cs @@ -21,9 +21,106 @@ 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 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, + 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, + 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 DeleteProjects, + int DeleteSnapshots, + int DeleteBackups); + + private sealed record PreviewBackupAnalysis(HashSet LiveSnapshotIds, int Add, int Delete); + + private sealed record ProjectMetadataConflictContext( + Project Current, + MetaProject Imported, + string SourceKey, + string? SourceMachineId, + long BaseRevision, + string BaseMachineId, + string BaseUpdatedUtc, + string LocalMachineId, + string DetectedUtc, + ProjectMetadataConflictValues Base, + ProjectMetadataConflictValues Local, + ProjectMetadataConflictValues Incoming, + ProjectMetadataMergePlan Plan); + + 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; + private readonly RepositoryLeaseService _repositoryLeaseService; private readonly Func? _projectColorResolver; private readonly Action? _projectColorApplier; private readonly ConcurrentDictionary _previewCache = @@ -35,12 +132,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 +168,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)) { @@ -263,6 +373,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(); @@ -298,6 +410,9 @@ private MetadataSyncResult ImportFromStoreInternal( foreach (string? tombstonedProjectId in tombstonedProjectIds) { + if (!opts.ApplyDestructiveTombstones) + continue; + if (!projectMap.TryGetValue(tombstonedProjectId, out int existingId)) continue; @@ -319,7 +434,6 @@ private MetadataSyncResult ImportFromStoreInternal( continue; ParsedProjectSettings parsedSettings = ParseProjectSettings(metaProject.SettingsJson); - TryApplyProjectColor(metaProject); if (projectMap.TryGetValue(metaProject.ExternalId, out int mappedProjectId)) { @@ -327,7 +441,8 @@ private MetadataSyncResult ImportFromStoreInternal( mappedProjectId, config, metaProject, - metaInfo?.WriterMachineId, + sourceKey, + ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); continue; @@ -360,7 +475,8 @@ private MetadataSyncResult ImportFromStoreInternal( existingByName.Id, config, metaProject, - metaInfo?.WriterMachineId, + sourceKey, + ResolveProjectWriterMachineId(metaProject, metaInfo), parsedSettings, pendingConflicts); projectMap[metaProject.ExternalId] = existingByName.Id; @@ -387,14 +503,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 @@ -405,8 +522,16 @@ 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); + metadataConflictChanged |= UpsertProjectMetadataMergeBase( + config, + sourceKey, + metaProject, + ResolveProjectWriterMachineId(metaProject, metaInfo), + BuildImportedValues(project, parsedSettings, config, newId)); projectMap[metaProject.ExternalId] = newId; importedProjects++; } @@ -526,14 +651,15 @@ 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)) + foreach (MetaTombstone tombstone in metaTombstones) { - if (backupExternalMap.TryGetValue(tombstone.EntityId, out int existingId)) + 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++; @@ -541,7 +667,7 @@ private MetadataSyncResult ImportFromStoreInternal( } } - if (missingBackupExternalIds.Count > 0) + if (missingBackupExternalIds.Count > 0 && opts.ApplyDestructiveTombstones) { foreach (string missingExternalId in missingBackupExternalIds) { @@ -558,9 +684,9 @@ private MetadataSyncResult ImportFromStoreInternal( } } - if (missingSnapshotExternalIds.Count > 0) + if (missingSnapshotExternalIds.Count > 0 && opts.ApplyDestructiveTombstones) { - int removedSnapshots = 0; + var removedSnapshotExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string? missingExternalId in missingSnapshotExternalIds) { Snapshot? snapshot = _repo.GetSnapshotByExternalId(missingExternalId); @@ -574,16 +700,14 @@ 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]); + if (snapshots > 0) + removedSnapshotExternalIds.Add(missingExternalId); } - if (removedSnapshots > 0) + if (removedSnapshotExternalIds.Count > 0 && opts.ExportMissingTombstonesOnImport) { - if (opts.ExportMissingTombstonesOnImport) - { - TryExportMissingSnapshotTombstones(rootPath, missingSnapshotExternalIds); - } + TryExportMissingSnapshotTombstones(rootPath, removedSnapshotExternalIds); } } @@ -904,73 +1028,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 List 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) @@ -1003,12 +1132,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(); @@ -1017,17 +1140,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)) { @@ -1035,140 +1158,217 @@ 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(); - var existingBackupPaths = _repo - .GetAllProjects() - .SelectMany(project => _repo.GetBackupsForProject(project.Id)) - .Select(backup => NormalizeStablePath(NormalizeBackupPathRel(backup.Path))) + PreviewTombstoneAnalysis tombstones = AnalyzePreviewTombstones( + metaTombstones, + projectExternalMap, + snapshotExternalMap, + backupExternalMap); + PreviewBackupAnalysis backups = AnalyzePreviewBackups( + metaBackups, + rootPath, + projectMap, + backupExternalMap, + tombstones.BackupIds); + int addSnapshots = CountPreviewSnapshots( + metaSnapshots, + projectMap, + snapshotExternalMap, + tombstones.SnapshotIds, + backups.LiveSnapshotIds); + + 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); - var tombstonedBackupIds = new HashSet(StringComparer.OrdinalIgnoreCase); - var tombstonedSnapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); - var liveSnapshotExternalIds = new HashSet(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; - foreach (MetaTombstone tombstone in metaTombstones) + var preview = new MetadataSyncPreview( + MetadataSyncStatus.Success, + rootPath, + store.DatabasePath, + addProjects, + projectCounts.Link, + addSnapshots, + addBackups, + tombstones.DeleteBackups + backups.Delete, + string.Empty) { - if (string.IsNullOrWhiteSpace(tombstone.EntityId)) - continue; + DeletedProjects = tombstones.DeleteProjects, + DeletedSnapshots = tombstones.DeleteSnapshots + }; - if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) - { - tombstonedBackupIds.Add(tombstone.EntityId); - if (backupExternalMap.ContainsKey(tombstone.EntityId)) - deleteBackups++; - } - else if (string.Equals(tombstone.EntityType, "snapshot", StringComparison.OrdinalIgnoreCase)) - { - tombstonedSnapshotIds.Add(tombstone.EntityId); - } + if (metaInfo != null) + { + _previewCache[rootPath] = (metaInfo.LastWriteUtc, preview); } - foreach (MetaBackup metaBackup in metaBackups) + return preview; + } + + private static PreviewProjectCounts CountPreviewProjects( + IEnumerable projects, + Dictionary projectMap, + IReadOnlyCollection localProjects, + bool allowCreateProjects) + { + int add = 0; + int link = 0; + foreach (MetaProject project in projects) { - if (string.IsNullOrWhiteSpace(metaBackup.ExternalId)) + if (string.IsNullOrWhiteSpace(project.ExternalId) || projectMap.ContainsKey(project.ExternalId)) continue; - if (!string.IsNullOrWhiteSpace(metaBackup.SnapshotExternalId) && - !tombstonedBackupIds.Contains(metaBackup.ExternalId)) + Project? local = localProjects.FirstOrDefault(candidate => + string.Equals(candidate.Name, project.Name, StringComparison.OrdinalIgnoreCase)); + if (local is not null) { - liveSnapshotExternalIds.Add(metaBackup.SnapshotExternalId); + if (string.IsNullOrWhiteSpace(local.ExternalId)) + link++; + projectMap[project.ExternalId] = local.Id; } - - if (tombstonedBackupIds.Contains(metaBackup.ExternalId)) - continue; - - if (!TryResolveBackupPath(rootPath, metaBackup.PathRel, out _)) + else if (allowCreateProjects) { - tombstonedBackupIds.Add(metaBackup.ExternalId); - if (backupExternalMap.ContainsKey(metaBackup.ExternalId)) - deleteBackups++; - continue; + add++; + projectMap[project.ExternalId] = -1; } - - if (!projectMap.ContainsKey(metaBackup.ProjectExternalId)) - continue; - - if (backupExternalMap.ContainsKey(metaBackup.ExternalId)) - continue; - - addBackups++; } - foreach (MetaSnapshot metaSnapshot in metaSnapshots) - { - if (string.IsNullOrWhiteSpace(metaSnapshot.ExternalId)) - continue; + return new PreviewProjectCounts(add, link); + } - if (!liveSnapshotExternalIds.Contains(metaSnapshot.ExternalId)) + 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 deleteProjects = 0; + int deleteSnapshots = 0; + int deleteBackups = 0; + foreach (MetaTombstone tombstone in tombstones.Where(tombstone => !string.IsNullOrWhiteSpace(tombstone.EntityId))) + { + if (string.Equals(tombstone.EntityType, "project", StringComparison.OrdinalIgnoreCase)) { - tombstonedSnapshotIds.Add(metaSnapshot.ExternalId); - continue; + deleteProjects += projectExternalMap.ContainsKey(tombstone.EntityId) ? 1 : 0; + } + else if (string.Equals(tombstone.EntityType, BackupEntityType, StringComparison.OrdinalIgnoreCase)) + { + backupIds.Add(tombstone.EntityId); + 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; } + } - if (tombstonedSnapshotIds.Contains(metaSnapshot.ExternalId)) - continue; + return new PreviewTombstoneAnalysis(backupIds, snapshotIds, deleteProjects, deleteSnapshots, deleteBackups); + } - if (!projectMap.ContainsKey(metaSnapshot.ProjectExternalId)) - continue; + private static PreviewBackupAnalysis AnalyzePreviewBackups( + IEnumerable backups, + string rootPath, + Dictionary projectMap, + IReadOnlyDictionary backupExternalMap, + HashSet tombstonedBackupIds) + { + var liveSnapshotIds = new HashSet(StringComparer.OrdinalIgnoreCase); + int add = 0; + int delete = 0; + foreach (MetaBackup backup in backups) + { + (int backupAdd, int backupDelete) = AnalyzePreviewBackup( + backup, + rootPath, + projectMap, + backupExternalMap, + tombstonedBackupIds, + liveSnapshotIds); + add += backupAdd; + delete += backupDelete; + } - if (snapshotExternalMap.ContainsKey(metaSnapshot.ExternalId)) - continue; + return new PreviewBackupAnalysis(liveSnapshotIds, add, delete); + } - addSnapshots++; - } + 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; - MetadataSyncPreview filesystemPreview = PreviewBackupFoldersFromDestination(rootPath, opts, store.DatabasePath); - addProjects += filesystemPreview.NewProjects; - addSnapshots += filesystemPreview.NewSnapshots; - addBackups += filesystemPreview.NewBackups; + 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); + } - var preview = new MetadataSyncPreview( - MetadataSyncStatus.Success, - rootPath, - store.DatabasePath, - addProjects, - linkProjects, - addSnapshots, - addBackups, - deleteBackups, - string.Empty); + bool isNew = projectMap.ContainsKey(backup.ProjectExternalId) && + !backupExternalMap.ContainsKey(backup.ExternalId); + return (isNew ? 1 : 0, 0); + } - if (metaInfo != null) + private static int CountPreviewSnapshots( + IEnumerable snapshots, + Dictionary projectMap, + IReadOnlyDictionary snapshotExternalMap, + HashSet tombstonedSnapshotIds, + HashSet 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) @@ -1184,11 +1384,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); @@ -1206,133 +1401,204 @@ private MetadataSyncResult ImportBackupFoldersFromDestination(string rootPath, M .GroupBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().Backup, StringComparer.OrdinalIgnoreCase); - foreach (IGrouping projectGroup in discovered.GroupBy(folder => folder.ProjectName, StringComparer.OrdinalIgnoreCase)) + var state = new LegacyImportState { - 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; + 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); - string projectName = projectGroup.Key; - string projectExternalId = BuildStableExternalId("legacy-project", rootPath, projectName); + return new MetadataSyncResult( + MetadataSyncStatus.Success, + state.ImportedProjects, + state.ImportedSnapshots, + state.ImportedBackups, + 0, + string.Empty) + { + AffectedProjectIds = [.. state.AffectedProjectIds], + RepairedBackups = state.RepairedBackups + }; + } - if (!projectsByName.TryGetValue(projectName, out Project? project)) - { - if (!opts.AllowCreateProjects) - continue; + 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; - 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 - }); + Project? project = ResolveLegacyProject(projectGroup, rootPath, options, config, state); + if (project is null) + return; - project = _repo.GetProjectById(projectId); - if (project is null) - continue; + RepairLegacyBackups(repairableFolders, rootPath, state); + ImportLegacyBackups(importableFolders, rootPath, project, state); + if (options.MarkNeedsRestoreOnImport && state.AffectedProjectIds.Contains(project.Id)) + _repo.UpdateProjectNeedsRestore(project.Id, true); + } - projectsByName[projectName] = project; - importedProjects++; - } - else if (string.IsNullOrWhiteSpace(project.ExternalId)) - { - _repo.UpdateProjectExternalId(project.Id, projectExternalId); - project = project with { ExternalId = projectExternalId }; - projectsByName[projectName] = project; - } + private static bool IsRepairableLegacyBackup( + LegacyBackupFolder folder, + Dictionary existingBackupByPath) => + existingBackupByPath.TryGetValue(NormalizeStablePath(folder.RelativePath), out Backup? backup) && + backup.IsImported && + backup.TotalBytes <= 0; - foreach (LegacyBackupFolder folder in repairableFolders) - { - string normalizedRelativePath = NormalizeStablePath(folder.RelativePath); - if (!existingBackupByPath.TryGetValue(normalizedRelativePath, out Backup? existingBackup)) - continue; + 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; - long sizeBytes = GetLegacyBackupFolderSize(rootPath, folder.RelativePath); - if (sizeBytes <= 0) - continue; + if (string.IsNullOrWhiteSpace(project.ExternalId)) + { + _repo.UpdateProjectExternalId(project.Id, externalId); + project = project with { ExternalId = externalId }; + state.ProjectsByName[projectName] = 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); + return project; + } - repairedBackups++; - affectedProjectIds.Add(existingBackup.ProjectId); - } + 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; - 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); + state.ProjectsByName[projectGroup.Key] = project; + state.ImportedProjects++; + return project; + } - if (!snapshotExternalMap.TryGetValue(snapshotExternalId, out int snapshotId)) - { - snapshotId = _repo.CreateSnapshotFromMetadata( - snapshotExternalId, - project.Id, - folder.CreatedUtc, - fileCount: 0, - totalBytes: sizeBytes); - importedSnapshots++; - } - - if (backupExternalMap.ContainsKey(backupExternalId)) - continue; + private void RepairLegacyBackups( + IEnumerable folders, + string rootPath, + LegacyImportState state) + { + foreach (string relativePath in folders.Select(folder => folder.RelativePath)) + { + string path = NormalizeStablePath(relativePath); + if (!state.ExistingBackupByPath.TryGetValue(path, out Backup? backup)) + continue; - _repo.CreateBackupFromMetadata( - backupExternalId, - project.Id, - snapshotId, - folder.CreatedUtc, - "manual", - sizeBytes, - folder.RelativePath, - rootPath, - string.Empty, - isProtected: false, - isImported: true, - backupMode: BackupModes.Full); + long sizeBytes = GetLegacyBackupFolderSize(rootPath, relativePath); + if (sizeBytes <= 0) + continue; - 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 }; - } + _repo.UpdateBackupTotalBytes(backup.Id, sizeBytes); + Snapshot? snapshot = _repo.GetSnapshotById(backup.SnapshotId); + if (snapshot is not null && snapshot.TotalBytes <= 0) + _repo.UpdateSnapshotTotalBytes(snapshot.Id, sizeBytes); - if (opts.MarkNeedsRestoreOnImport && affectedProjectIds.Contains(project.Id)) - { - _repo.UpdateProjectNeedsRestore(project.Id, true); - } + state.RepairedBackups++; + state.AffectedProjectIds.Add(backup.ProjectId); } + } - return new MetadataSyncResult( - MetadataSyncStatus.Success, - importedProjects, - importedSnapshots, - importedBackups, - 0, - string.Empty) - { - AffectedProjectIds = [.. affectedProjectIds], - RepairedBackups = repairedBackups - }; + private void ImportLegacyBackups( + IEnumerable folders, + string rootPath, + Project project, + LegacyImportState state) + { + foreach (LegacyBackupFolder folder in folders) + ImportLegacyBackup(folder, rootPath, project, state); + } + + 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; + + _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) + private MetadataSyncPreview PreviewBackupFoldersFromDestination( + string rootPath, + MetadataSyncOptions opts, + string databasePath, + IReadOnlySet? representedProjectNames = null, + IReadOnlySet? representedBackupPaths = null) { if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) { @@ -1349,6 +1615,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 @@ -1357,6 +1625,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; @@ -1364,29 +1634,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( @@ -1401,6 +1660,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(); @@ -1410,8 +1688,9 @@ private static IReadOnlyList DiscoverLegacyBackupFolders(str { projectDirs = Directory.EnumerateDirectories(rootPath); } - catch + catch (Exception ex) { + RuntimeLog.WriteVerbose($"[MetadataSync] Legacy backup root could not be enumerated: {ex.Message}"); return result; } @@ -1728,8 +2007,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); @@ -1833,8 +2113,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 && @@ -1850,59 +2131,86 @@ private static bool IsVaultSyncTransientTempPath(string path) } } - private static void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollection missingExternalIds) + private void TryExportMissingBackupTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { - TryExportTombstones( - rootPath, - BackupEntityType, - missingExternalIds, - Environment.MachineName, - "Missing backup tombstone export"); + string machineId = Environment.MachineName; + TryExportTombstonesCore( + new TombstoneExportContext( + rootPath, + BackupEntityType, + missingExternalIds, + machineId, + "Missing backup tombstone export", + UnknownAppVersion, + ResolveLeaseOwnerId(machineId)), + _repositoryLeaseService); } - private static void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollection missingExternalIds) + private void TryExportMissingSnapshotTombstones(string rootPath, IReadOnlyCollection missingExternalIds) { - TryExportTombstones( - rootPath, - "snapshot", - missingExternalIds, - Environment.MachineName, - "Missing snapshot tombstone export"); + string machineId = Environment.MachineName; + TryExportTombstonesCore( + new TombstoneExportContext( + rootPath, + "snapshot", + missingExternalIds, + machineId, + "Missing snapshot tombstone export", + UnknownAppVersion, + ResolveLeaseOwnerId(machineId)), + _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( - rootPath, - "project", - [projectExternalId], - machineId, - "Project tombstone export"); + var leaseService = new RepositoryLeaseService(); + TryExportTombstonesCore( + new TombstoneExportContext( + rootPath, + "project", + [projectExternalId], + machineId, + "Project tombstone export", + UnknownAppVersion, + leaseOwnerId ?? CreateCompatibilityInstallationId(machineId)), + leaseService); } - private static void TryExportTombstones( - string rootPath, - string entityType, - IReadOnlyCollection externalIds, - string machineId, - string logLabel, - string appVersion = "unknown") + private static void TryExportTombstonesCore( + 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( + context.RootPath, + CreateLeaseRequest(context.LeaseOwnerId, context.MachineId, context.LogLabel, context.AppVersion)); + if (!leaseResult.Acquired) + { + Console.WriteLine($"[MetadataSync] {context.LogLabel} skipped: {leaseResult.Inspection.Message}"); return; + } - var store = new MetadataStore(rootPath); + using RepositoryLeaseHandle lease = leaseResult.Handle!; + var store = new MetadataStore(context.RootPath); try { + if (!lease.IsOwner) + return; store.EnsureSchema(); } 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; } @@ -1910,21 +2218,23 @@ private static void TryExportTombstones( MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: false); try { + if (!lease.IsOwner) + return; 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}"); } } @@ -1948,6 +2258,7 @@ private static MetaInfo BuildUpdatedTombstoneMetaInfo( }; } + metaInfo.SchemaVersion = MetadataStore.CurrentSchemaVersion; metaInfo.LastWriteUtc = now; metaInfo.WriterMachineId = machineId; if (updateExistingAppVersion) @@ -2002,44 +2313,99 @@ public async Task ExportProjectToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); - TimeSpan[] retryDelays = - [ - TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5) - ]; + 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; + return await ExecuteStoreWriteWithRetryAsync( + () => ExportProjectToStoreInternal( + rootPath, + projectId, + appVersion, + machineId, + destinationLease, + useDeferredStore), + "Project export", + ct) + .ConfigureAwait(false); + } + finally + { + metadataIoGate.Release(); + } + } - for (int attempt = 0; attempt <= retryDelays.Length; attempt++) + private static async Task ExecuteStoreWriteWithRetryAsync( + Func write, + string operationLabel, + CancellationToken ct) + { + for (int attempt = 0; attempt <= StoreRetryDelays.Length; attempt++) + { + try { - try + return write(); + } + catch (SqliteException ex) when (IsCannotOpenOrLocked(ex)) + { + if (attempt >= StoreRetryDelays.Length) { - return ExportProjectToStoreInternal(rootPath, projectId, appVersion, machineId); + Console.WriteLine($"[MetadataSync] {operationLabel} failed after retries: {ex.Message}"); + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, ex.Message); } - 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); - } - TimeSpan delay = retryDelays[attempt]; - Console.WriteLine($"[MetadataSync] Project export store locked; retrying in {delay.TotalMilliseconds:0}ms."); - await Task.Delay(delay, ct).ConfigureAwait(false); - } + TimeSpan delay = StoreRetryDelays[attempt]; + Console.WriteLine($"[MetadataSync] {operationLabel} store locked; retrying in {delay.TotalMilliseconds:0}ms."); + await Task.Delay(delay, ct).ConfigureAwait(false); } - - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Project export failed after retries."); } - finally + + return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, $"{operationLabel} failed after retries."); + } + + 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(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 +2413,25 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); } - TryFlushDeferredExport(rootPath); + MetadataSyncResult? destinationFailure = ValidateDestinationLease( + rootPath, appVersion, machineId, destinationLease); + if (destinationFailure is not null) + return destinationFailure; - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) - { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; - } + 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)) @@ -2079,42 +2449,40 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro 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 metaInfo = BuildUpdatedTombstoneMetaInfo( + store, + now, + appVersion, + machineId, + updateExistingAppVersion: true); + if (!TryPrepareGuardedProjectWrite( + rootPath, + store, + project, + projectExternalId, + now, + machineId, + out GuardedProjectWrite? guardedWrite, + out string revisionFailure)) { - metaInfo.LastWriteUtc = now; - metaInfo.WriterAppVersion = appVersion; - metaInfo.WriterMachineId = machineId; + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, revisionFailure); } try { + if (!activeLease.IsOwner) + return LostLeaseFailure(); 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 - }); + 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}"); @@ -2131,11 +2499,11 @@ private MetadataSyncResult ExportProjectToStoreInternal(string rootPath, int pro Console.WriteLine($"[MetadataSync] Project export complete for project '{project.Name}' to '{storeRoot}'."); LogStoreCounts(store); - if (isDeferred) - { - if (TryFlushDeferredExport(rootPath)) - return exportResult; + if (!useDeferredStore) + SaveSuccessfulProjectWriteBase(rootPath, guardedWrite!); + if (useDeferredStore) + { return MetadataSyncResult.Failure( MetadataSyncStatus.WriteFailed, "Project export queued: destination not writable. Will retry when available."); @@ -2157,36 +2525,31 @@ public async Task ExportBackupToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); - 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(rootPath, backupId, appVersion, machineId, forceBackfill); - } - 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); - } - } + if (string.IsNullOrWhiteSpace(rootPath)) + return MetadataSyncResult.Failure(MetadataSyncStatus.InvalidPath, InvalidRootPathMessage); - return MetadataSyncResult.Failure(MetadataSyncStatus.WriteFailed, "Export failed after retries."); + 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; + return await ExecuteStoreWriteWithRetryAsync( + () => ExportBackupToStoreInternal( + rootPath, + backupId, + appVersion, + machineId, + forceBackfill, + destinationLease, + useDeferredStore), + "Backup export", + ct) + .ConfigureAwait(false); } finally { @@ -2194,7 +2557,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)) { @@ -2202,54 +2572,32 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back 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."); - } - - 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."); - } + if (!TryResolveBackupExportEntities(backupId, out BackupExportEntities? entities, out MetadataSyncResult? entityFailure)) + return entityFailure!; - 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; - TryFlushDeferredExport(rootPath); + MetadataSyncResult? destinationFailure = ValidateDestinationLease( + rootPath, appVersion, machineId, destinationLease); + if (destinationFailure is not null) + return destinationFailure; - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) - { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; - } + 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)) @@ -2263,87 +2611,47 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back 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 metaInfo = BuildUpdatedTombstoneMetaInfo( + store, + now, + appVersion, + machineId, + updateExistingAppVersion: true); + if (!TryPrepareGuardedProjectWrite( + rootPath, + store, + project, + projectExternalId, + now, + machineId, + out GuardedProjectWrite? guardedWrite, + out string revisionFailure)) { - metaInfo.LastWriteUtc = now; - metaInfo.WriterAppVersion = appVersion; - metaInfo.WriterMachineId = machineId; + return MetadataSyncResult.Failure(MetadataSyncStatus.RepositoryBusy, revisionFailure); } - int exportedProjects = 0; - int exportedSnapshots = 0; - int exportedBackups = 0; - bool backfilled = forceBackfill || !store.HasProject(projectExternalId); - + BackupExportCounts counts; try { - 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 - }); - 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) - }); - exportedBackups = 1; - } - }); + if (!activeLease.IsOwner) + return LostLeaseFailure(); + counts = WriteBackupExport( + store, + metaInfo, + entities, + new BackupExportWriteContext( + projectExternalId, + snapshotExternalId, + backupExternalId, + now, + machineId, + 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)) { @@ -2353,20 +2661,19 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back 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 (isDeferred) + if (!useDeferredStore) + SaveSuccessfulProjectWriteBase(rootPath, guardedWrite!); + if (useDeferredStore) { - if (TryFlushDeferredExport(rootPath)) - return exportResult; - return MetadataSyncResult.Failure( MetadataSyncStatus.WriteFailed, "Export queued: destination not writable. Will retry when available."); @@ -2375,10 +2682,118 @@ private MetadataSyncResult ExportBackupToStoreInternal(string rootPath, int back return exportResult; } - public static void ExportBackupTombstoneToStore(string rootPath, string backupExternalId, string appVersion, string machineId) + private bool TryResolveBackupExportEntities( + int backupId, + out BackupExportEntities? entities, + out MetadataSyncResult? failure) { - ExportBackupTombstoneToStoreAsync(rootPath, backupExternalId, appVersion, machineId, CancellationToken.None) - .GetAwaiter().GetResult(); + 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, + context.ProjectRecord, + context.ExpectedProjectRevision); + exportedProjects = 1; + exportedSnapshots = snapshots; + exportedBackups = backups; + return; + } + + 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, + 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, + string appVersion, + string machineId, + string? leaseOwnerId = null) + { + ExportBackupTombstoneToStoreAsync( + rootPath, + backupExternalId, + appVersion, + machineId, + leaseOwnerId, + CancellationToken.None) + .GetAwaiter().GetResult(); } public static async Task ExportBackupTombstoneToStoreAsync( @@ -2386,6 +2801,7 @@ public static async Task ExportBackupTombstoneToStoreAsync( string backupExternalId, string appVersion, string machineId, + string? leaseOwnerId = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(backupExternalId)) @@ -2396,32 +2812,50 @@ public static async Task ExportBackupTombstoneToStoreAsync( try { await WaitForNetworkReadyAsync(rootPath, ct).ConfigureAwait(false); - TimeSpan[] retryDelays = - [ - TimeSpan.FromMilliseconds(200), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5) - ]; + 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)); + bool useDeferredStore = leaseResult.Status == RepositoryLeaseAcquireStatus.Unavailable; + if (!leaseResult.Acquired && !useDeferredStore) + { + Console.WriteLine($"[MetadataSync] Tombstone export skipped: {leaseResult.Inspection.Message}"); + return; + } - for (int attempt = 0; attempt <= retryDelays.Length; attempt++) + using RepositoryLeaseHandle? destinationLease = leaseResult.Handle; + for (int attempt = 0; attempt <= StoreRetryDelays.Length; attempt++) { try { - ExportBackupTombstoneInternal(rootPath, backupExternalId, appVersion, machineId); + ExportBackupTombstoneInternal( + context, + leaseService, + destinationLease, + useDeferredStore); return; } 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); + TryExportBackupTombstoneToDeferred( + 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); } @@ -2456,59 +2890,89 @@ private static StringComparer GetPathComparer() => : StringComparer.Ordinal; private static void TryExportBackupTombstoneToDeferred( - string rootPath, - string backupExternalId, - string appVersion, - string machineId) + TombstoneExportContext context, + RepositoryLeaseService leaseService) { try { - string deferredRoot = GetDeferredExportRoot(rootPath); + string deferredRoot = GetDeferredExportRoot(context.RootPath); + Directory.CreateDirectory(deferredRoot); + RepositoryLeaseAcquireResult leaseResult = leaseService.TryAcquire( + deferredRoot, + CreateLeaseRequest( + context.LeaseOwnerId, + context.MachineId, + "deferred-backup-tombstone-export", + context.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; MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: true); + if (!lease.IsOwner) + return; 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) + private static void ExportBackupTombstoneInternal( + TombstoneExportContext context, + RepositoryLeaseService leaseService, + RepositoryLeaseHandle? destinationLease, + bool useDeferredStore) { - TryFlushDeferredExport(rootPath); - string storeRoot = rootPath; - bool isDeferred = false; - string destMetaDir = GetMetaDir(rootPath); - if (!TryEnsureMetadataDirWritable(destMetaDir)) - { - storeRoot = GetDeferredExportRoot(rootPath); - isDeferred = true; - } + if (!CanWriteTombstoneDestination(context, leaseService, destinationLease)) + return; + + string storeRoot = useDeferredStore ? GetDeferredExportRoot(context.RootPath) : context.RootPath; + if (useDeferredStore) + Directory.CreateDirectory(storeRoot); + RepositoryLeaseAcquireResult? deferredLeaseResult = useDeferredStore + ? leaseService.TryAcquire( + storeRoot, + CreateLeaseRequest( + context.LeaseOwnerId, + context.MachineId, + "deferred-backup-tombstone-export", + context.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)) { - 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; } @@ -2516,33 +2980,121 @@ private static void ExportBackupTombstoneInternal(string rootPath, string backup MetaInfo metaInfo = BuildUpdatedTombstoneMetaInfo( store, now, - appVersion, - machineId, + context.AppVersion, + context.MachineId, updateExistingAppVersion: true); try { + if (!activeLease.IsOwner) + return; 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 (isDeferred) - { - TryFlushDeferredExport(rootPath); - } + if (useDeferredStore) + 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) => 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) ? UnknownAppVersion : appVersion.Trim()); + + private static string CreateCompatibilityInstallationId(string machineLabel) + { + 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"); + } + + 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 +3143,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); + } + + private static bool HasDeferredExport(string rootPath) => + File.Exists(new MetadataStore(GetDeferredExportRoot(rootPath)).DatabasePath); - _ = 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 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 @@ -2657,23 +3235,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 - }); + if (!store.TryUpsertProject(projectRecord, expectedProjectRevision)) + throw new MetadataRevisionConflictException("Project metadata changed after its revision was inspected."); foreach (Snapshot? snap in snapshots) { @@ -2725,23 +3297,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++; } @@ -2753,6 +3314,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 @@ -2800,50 +3387,261 @@ 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) { - settings["avatarColor"] = color; + expectedRevision = 0; } + else if (mergeBase is not null) + { + if (mergeBase.Revision <= 0 || mergeBase.Revision != existing.Revision) + { + failure = "Project metadata changed on another machine. Import and review that revision before writing."; + return false; + } - settings["encryptionPolicy"] = ProjectEncryptionPolicy.Normalize(project.EncryptionPolicy); - settings["encryptionKeyRef"] = string.IsNullOrWhiteSpace(project.EncryptionKeyRef) - ? null - : project.EncryptionKeyRef; - 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(); + 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; + } - return JsonSerializer.Serialize(settings); + ProjectMetadataConflictValues values = BuildExportedProjectValues(project, config); + long nextRevision = checked(expectedRevision + 1); + 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 = nextRevision, + BaseRevision = expectedRevision, + FieldProvenanceJson = BuildFieldProvenanceJson(existing, values, machineId, nextRevision, updatedUtc), + ResolutionJson = BuildResolutionJson(config, sourceKey, projectExternalId) + }; + write = new GuardedProjectWrite(record, expectedRevision, values); + return true; } - catch + catch (Exception ex) { - return "{}"; + RuntimeLog.WriteVerbose($"[MetadataSync] Could not prepare guarded project metadata: {ex.Message}"); + failure = "Project metadata could not be prepared safely for writing."; + return false; + } + } + + private void SaveSuccessfulProjectWriteBase(string destinationRoot, GuardedProjectWrite write) + { + try + { + AppConfig config = _configStore.Load(); + string sourceKey = BuildMetadataSourceKey( + Path.GetFullPath(destinationRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + 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); + if (changed) + _configStore.Save(config); + } + catch (Exception ex) + { + 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 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, - string? EncryptionKeyRef, string PreferredDestinationId, string RestoreMode, string VerificationPolicy, bool AutoBackupEnabled, string Tags, + bool HasAvatarColor, bool HasEncryptionPolicy, - bool HasEncryptionKeyRef, bool HasPreferredDestinationId, bool HasRestoreMode, bool HasVerificationPolicy, @@ -2853,53 +3651,36 @@ 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 { 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)) @@ -2941,15 +3722,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, @@ -2958,34 +3739,38 @@ 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( + string.Empty, + ProjectEncryptionPolicy.Inherit, + string.Empty, + ProjectRestoreMode.Direct, + ProjectVerificationPolicy.Always, + true, + string.Empty, + HasAvatarColor: false, + HasEncryptionPolicy: false, + HasPreferredDestinationId: false, + HasRestoreMode: false, + HasVerificationPolicy: false, + HasAutoBackupEnabled: false, + HasTags: false); + private bool ApplyImportedProjectSettings( int projectId, AppConfig config, MetaProject metaProject, + string sourceKey, string? sourceMachineId, ParsedProjectSettings parsedSettings, IList pendingConflicts) { - if (!parsedSettings.HasEncryptionPolicy && - !parsedSettings.HasEncryptionKeyRef && + if (!parsedSettings.HasAvatarColor && + !parsedSettings.HasEncryptionPolicy && !parsedSettings.HasPreferredDestinationId && !parsedSettings.HasRestoreMode && !parsedSettings.HasVerificationPolicy && @@ -2999,6 +3784,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) @@ -3011,9 +3800,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) @@ -3021,7 +3807,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 @@ -3038,54 +3824,203 @@ 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) && - nextAutoBackupEnabled == currentAutoBackupEnabled && - string.Equals(nextTags, currentTags, StringComparison.Ordinal)) - { - 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); - if (!conflictValuesDiffer) + var localValues = new ProjectMetadataConflictValues { - _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); + 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 + }; + + 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)) + { + bool changed = RemoveProjectMetadataConflict(projectId, pendingConflicts); + changed |= UpsertProjectMetadataMergeBase(config, sourceKey, metaProject, sourceMachineId, incomingValues); + return changed; + } + + if (!plan.HasConflicts) + { + ApplyProjectMetadataValues(config, current, metaProject.ExternalId, plan.Merged, currentKeyRef); + bool changed = RemoveProjectMetadataConflict(projectId, pendingConflicts); + changed |= UpsertProjectMetadataMergeBase(config, sourceKey, metaProject, sourceMachineId, incomingValues); + return changed; } return UpsertProjectMetadataConflict( - current, - metaProject, - sourceMachineId, - currentPreferredDestinationId, - currentRestoreMode, - currentVerificationPolicy, - currentTags, - nextPreferredDestinationId, - nextRestoreMode, - nextVerificationPolicy, - nextTags, + new ProjectMetadataConflictContext( + current, + metaProject, + 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, + plan), pendingConflicts); } + private string ResolveLocalMetadataWriterId() + { + try + { + return _installationIdentityProvider?.GetOrCreate() ?? "this-installation"; + } + catch + { + return "this-installation"; + } + } + + 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); + 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) && + ProjectMetadataFieldProvenanceEqual(existing.FieldProvenance, fieldProvenance)) + { + 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; + 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, + 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.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) && + ProjectMetadataConflictValuesEqual(resolution.Imported, incomingValues)); + } + private static bool ApplyImportedProjectAutoBackupSetting(AppConfig config, int projectId, bool enabled) { config.Backups.AutoBackupDisabledProjects ??= []; @@ -3115,42 +4050,33 @@ 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) ? "unknown" : sourceMachineId, + SourceMachineId = string.IsNullOrWhiteSpace(context.SourceMachineId) ? UnknownAppVersion : context.SourceMachineId, + 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), - Local = new ProjectMetadataConflictValues - { - PreferredDestinationId = currentPreferredDestinationId, - RestoreMode = currentRestoreMode, - VerificationPolicy = currentVerificationPolicy, - Tags = currentTags - }, - Imported = new ProjectMetadataConflictValues - { - PreferredDestinationId = importedPreferredDestinationId, - RestoreMode = importedRestoreMode, - VerificationPolicy = importedVerificationPolicy, - Tags = importedTags - } + ConflictingFields = [.. context.Plan.ConflictingFields], + Base = context.Base, + Local = context.Local, + Imported = context.Incoming, + KeepLocalResult = context.Plan.KeepLocalResult, + AcceptImportedResult = context.Plan.AcceptImportedResult }; ProjectMetadataConflictRecord? existing = pendingConflicts.FirstOrDefault(conflict => @@ -3164,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; @@ -3172,8 +4105,19 @@ 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.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; existing.Imported = next.Imported; + existing.KeepLocalResult = next.KeepLocalResult; + existing.AcceptImportedResult = next.AcceptImportedResult; return true; } @@ -3184,56 +4128,99 @@ 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 && + 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) && - 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) { - 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) + { + 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(metaProject.ExternalId)) + 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 }; } @@ -3246,7 +4233,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); @@ -3263,12 +4253,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); @@ -3281,5 +4275,6 @@ public enum MetadataSyncStatus InvalidPath, InvalidStore, Incompatible, + RepositoryBusy, WriteFailed } diff --git a/src/VaultSync.Core/Services/NetworkMountService.cs b/src/VaultSync.Core/Services/NetworkMountService.cs index bc5c3bc3..8ecd9e35 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() @@ -49,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) { @@ -120,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; @@ -136,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); @@ -305,56 +318,17 @@ 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)) { - 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. @@ -370,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", @@ -393,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); @@ -401,48 +430,53 @@ 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)) { - 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(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()); } - 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(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}"); } } - private static string SanitizeMountError(string stderr, string? password, string share, string shareDisplay) + 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)}"); + } + + 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); @@ -450,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; } @@ -473,56 +502,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,54 +541,20 @@ 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) - { - 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)) - { - mountLine = line; - 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) @@ -722,8 +688,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,17 +715,23 @@ 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('@')) { - 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) + { + subPath = string.Join('/', parts.Skip(2)); } return true; @@ -767,60 +745,71 @@ 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; } - private static string AppendShareSubPath(string mountPoint, string subPath) + 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); + } + + internal static string AppendShareSubPath(string mountPoint, string subPath) { if (string.IsNullOrWhiteSpace(subPath)) return mountPoint; @@ -862,7 +851,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"; @@ -873,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) @@ -917,47 +906,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 (string mountPoint in mounts.Select(mount => mount.MountPoint)) { - 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(mountPoint, StringComparison.OrdinalIgnoreCase)) continue; - if (mountedAt.Length > candidate.Length) - candidate = mountedAt; + if (mountPoint.Length > candidate.Length) + candidate = mountPoint; } if (string.IsNullOrWhiteSpace(candidate)) 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.Core/Services/RepositoryLeaseService.cs b/src/VaultSync.Core/Services/RepositoryLeaseService.cs new file mode 100644 index 00000000..8708d7c4 --- /dev/null +++ b/src/VaultSync.Core/Services/RepositoryLeaseService.cs @@ -0,0 +1,916 @@ +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 static 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); + 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)) + { + 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; + 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.")); + + 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 static IReadOnlyList ListEvidence(string rootPath) + { + string databasePath; + try + { + databasePath = GetDatabasePath(rootPath); + } + catch (Exception ex) when (IsStorageException(ex)) + { + return []; + } + 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 static 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 static 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 is null || 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, + Pooling = false + }; + 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; } = CurrentProtocolVersion; + public string InstallationId { get; set; } = string.Empty; + 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; + 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; } = CurrentProtocolVersion; + public string InstallationId { get; set; } = string.Empty; + 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; + 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; } = string.Empty; + 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(); + RepositoryLeaseService.TryRelease(_rootPath, Lease.InstallationId, Lease.Nonce); + } +} diff --git a/src/VaultSync.Core/Services/RobocopyRunner.cs b/src/VaultSync.Core/Services/RobocopyRunner.cs index 6fb72470..c724b8c2 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,179 +271,55 @@ 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(); - - // Skip comments / empty - if (line.Length == 0 || line.StartsWith("#")) + if (!TryNormalizeIgnorePattern(raw, out string pattern, out bool isDirectory)) continue; - // Handle ** wildcard (common in gitignore-style). Robocopy doesn't support it. - // If the pattern looks like "bin/**" treat it as a dir exclude "bin". - if (line.Contains("/**") || line.Contains("\\**")) - { - string d = line.Replace("/**", string.Empty) - .Replace("\\**", string.Empty) - .TrimEnd('/'); - - if (!string.IsNullOrWhiteSpace(d)) - dirs.Add(NormalizeRobocopyGlob(d)); - - continue; - } - - // Very simple parsing: - // - trailing slash => treat as directory pattern - // - everything else => file/glob pattern - // - strip leading "./" - if (line.StartsWith("./")) line = line.Substring(2); - - 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 - { - files.Add(NormalizeRobocopyGlob(line)); - } - } - } - - private static string? ResolvePresetFile(string presetName) - { - if (string.IsNullOrWhiteSpace(presetName)) - return null; - - // 1) Environment override - string? envDir = Environment.GetEnvironmentVariable("VAULTSYNC_PRESETS_DIR"); - if (!string.IsNullOrWhiteSpace(envDir)) - { - string? candidate = ResolvePresetFileInDirectory(envDir, presetName); - if (!string.IsNullOrWhiteSpace(candidate)) - return candidate; - } - - // 2) App-installed presets folder: /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; + (isDirectory ? dirs : files).Add(pattern); } - - // 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) + internal static bool TryNormalizeIgnorePattern(string raw, out string pattern, out bool isDirectory) { - 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 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 indexPath = Path.Combine(directory, "presets.index.json"); - if (!File.Exists(indexPath)) - return null; - - Dictionary map; - lock (s_presetIndexLock) + string directory = line[..^3].TrimEnd('/', '\\'); + if (directory.StartsWith("**/", StringComparison.Ordinal) || + directory.StartsWith("**\\", StringComparison.Ordinal)) { - 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; + directory = directory[3..]; } - 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; + 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) @@ -539,16 +410,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/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/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..329c0bc6 --- /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(PruneFiles( + Path.Combine(tempRoot, "VaultSync", "updates"), + static _ => true, + 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..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); @@ -64,6 +65,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/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/GitHubUpdateService.cs b/src/VaultSync.UI/Services/GitHubUpdateService.cs index b52e0c80..957394aa 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" @@ -230,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")); @@ -418,7 +433,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; } @@ -433,9 +448,123 @@ 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 (!TryGetReleaseManifestAsset( + release.Assets, + releaseTag, + out GitHubAsset? manifestAsset, + out Uri? manifestUri, + out string? expectedHash)) + return null; + + try + { + 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, + 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) + { + return null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return 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 (assets is null || assets.Count == 0) + 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) return (null, null, 0, null, null, null, 0); List suffixes = GetPlatformSuffixes(); @@ -447,35 +576,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) + internal 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 +633,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 +672,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 +772,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/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/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/ReleaseManifestVerifier.cs b/src/VaultSync.UI/Services/ReleaseManifestVerifier.cs new file mode 100644 index 00000000..ee3bd86e --- /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 partial class ReleaseManifestVerifier + { + internal const string ManifestName = "vaultsync-release-manifest.json"; + 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 = + [ + "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 != SupportedSchemaVersion || + release is null || + release.Version is null || + !VersionPattern().IsMatch(release.Version) || + !HasValidPredecessors(release)) + { + return false; + } + + return + 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) && + (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 => + 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, + Dictionary 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/{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) && + 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'); + + [GeneratedRegex("^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] + private static partial Regex VersionPattern(); + + 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/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/Services/ThemeColor.cs b/src/VaultSync.UI/Services/ThemeColor.cs new file mode 100644 index 00000000..217a8412 --- /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('#')) + 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/Services/VerifiedReleaseAssetCache.cs b/src/VaultSync.UI/Services/VerifiedReleaseAssetCache.cs new file mode 100644 index 00000000..23f7ed1f --- /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 && + !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)) + { + // Temporary-file cleanup is best effort; update checks must continue. + } + } + + 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/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 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 ef73826a..c053f1ee 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) { @@ -235,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) @@ -487,7 +488,7 @@ private int ScanDestinationsForUntrackedBackups(List projects, List 0) @@ -1026,7 +1027,7 @@ preparation.DisabledProjects is not { } disabled || { foreach ((_, DestinationResolution resolution) in destinationResolutions) { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } } @@ -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..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); @@ -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); } } } @@ -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..acf0702a 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(); @@ -72,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/AppViewModel.cs b/src/VaultSync.UI/ViewModels/AppViewModel.cs index 95830f9f..e353b104 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 InstallationIdentityService _installationIdentityProvider; private readonly CredentialVault _credentialVault; private readonly ProjectEncryptionEnrollmentService _projectEncryptionEnrollmentService; private readonly INotificationService _notificationService; 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/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/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.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.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/ViewModels/SettingsViewModel.cs b/src/VaultSync.UI/ViewModels/SettingsViewModel.cs index 69709d52..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; @@ -155,6 +156,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; @@ -166,12 +170,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"; @@ -232,12 +239,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; } } @@ -557,11 +580,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 +659,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()); @@ -642,6 +678,8 @@ public SettingsViewModel(LocalizationService localizationService, IAppConfigStor 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); @@ -652,6 +690,9 @@ public SettingsViewModel(LocalizationService localizationService, IAppConfigStor _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()); @@ -791,6 +832,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(); @@ -2053,6 +2095,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; @@ -2299,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; @@ -2752,6 +2825,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!; @@ -2763,12 +2840,14 @@ 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!; 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; } @@ -3103,9 +3182,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"))); @@ -3167,22 +3254,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(() => { @@ -3210,7 +3282,7 @@ private async Task TestDestinationAsync(BackupDestinationViewModel? dest) } finally { - _networkMountService.Cleanup(resolution); + NetworkMountService.Cleanup(resolution); } }); DestinationTested?.Invoke(destModel, result.success, result.writable, result.message); @@ -3247,6 +3319,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}"); @@ -3292,6 +3381,9 @@ void Raise() { _scanBackupIndexRepairPlanCommand?.RaiseCanExecuteChanged(); _applyBackupIndexRepairPlanCommand?.RaiseCanExecuteChanged(); + _acceptProjectMetadataConflictCommand?.RaiseCanExecuteChanged(); + _keepLocalProjectMetadataConflictCommand?.RaiseCanExecuteChanged(); + _undoProjectMetadataResolutionCommand?.RaiseCanExecuteChanged(); } if (Dispatcher.UIThread.CheckAccess()) @@ -3728,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 @@ -4073,12 +4173,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 + { + true => L("Common.Yes", "Yes"), + false => L("Common.No", "No"), + null => "-" + }; + private static string FormatConflictUtc(string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -4110,6 +4261,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) @@ -4129,11 +4378,31 @@ 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 = SelectConflictResult( + conflict.AcceptImportedResult, + conflict.Imported); + 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"); + 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); @@ -4198,6 +4467,24 @@ 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."); + var repo = _repositoryFactory.Create(cfg); + Project? current = repo.GetProjectById(item.ProjectId) + ?? throw new InvalidOperationException("The project no longer exists."); + 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)); + 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); @@ -4247,16 +4534,106 @@ 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 ProjectMetadataConflictValues SelectConflictResult( + ProjectMetadataConflictValues? preferred, + ProjectMetadataConflictValues? fallback) + => preferred?.AutoBackupEnabled.HasValue == true + ? preferred + : fallback ?? new ProjectMetadataConflictValues(); + + 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 ??= []; + 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) && + 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), + UndoAvailable = true, + Local = conflict.Local ?? new ProjectMetadataConflictValues(), + Imported = conflict.Imported ?? new ProjectMetadataConflictValues(), + Result = string.Equals(decision, "accept-imported", StringComparison.OrdinalIgnoreCase) + ? SelectConflictResult(conflict.AcceptImportedResult, conflict.Imported) + : SelectConflictResult(conflict.KeepLocalResult, conflict.Local) + }); + + 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 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) + 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/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)); - } } diff --git a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml index 752a2001..1f73beca 100644 --- a/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml +++ b/src/VaultSync.UI/Views/MetadataSyncReviewWindow.axaml @@ -6,62 +6,145 @@ 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"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + + + + + + + + + + + + + + + + @@ -1806,6 +1888,40 @@ FontSize="12" /> + + + + + + +