diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 3dfd880..57ac2c0 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -1,38 +1,128 @@
-name: Native desktop build
+name: Desktop validation
on:
- workflow_dispatch:
+ push:
+ branches: [main]
+ tags: ['v*']
pull_request:
+ workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: desktop-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
+ quality:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Rust
+ run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt,clippy
+ - name: Format
+ run: cargo +1.95.0 fmt --all -- --check
+ - name: Lint
+ run: cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings
+ - name: Package verification tests
+ run: python3 -m unittest discover -s tests -p 'test_*.py'
+
desktop:
+ needs: quality
+ timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- - runner: windows-latest
- artifact: tar-vault-sync-windows
- executable: target/release/tar-vault-sync.exe
- - runner: macos-latest
- artifact: tar-vault-sync-macos
- executable: target/release/tar-vault-sync
- - runner: ubuntu-latest
- artifact: tar-vault-sync-linux
- executable: target/release/tar-vault-sync
+ - runner: windows-2025
+ platform: windows
+ arch: amd64
+ target: x86_64-pc-windows-msvc
+ suffix: .exe
+ - runner: windows-11-arm
+ platform: windows
+ arch: arm64
+ target: aarch64-pc-windows-msvc
+ suffix: .exe
+ - runner: macos-15-intel
+ platform: macos
+ arch: amd64
+ target: x86_64-apple-darwin
+ suffix: ''
+ - runner: macos-15
+ platform: macos
+ arch: arm64
+ target: aarch64-apple-darwin
+ suffix: ''
+ - runner: ubuntu-24.04
+ platform: linux
+ arch: amd64
+ target: x86_64-unknown-linux-gnu
+ suffix: ''
+ - runner: ubuntu-24.04-arm
+ platform: linux
+ arch: arm64
+ target: aarch64-unknown-linux-gnu
+ suffix: ''
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- - name: Rust stable
- run: rustup update stable
- - name: Tests
- run: cargo test --workspace --locked
- - name: Desktop binary
- run: cargo build --release --locked
+ - name: Install Rust
+ run: rustup toolchain install 1.95.0 --profile minimal --target ${{ matrix.target }}
+ - name: Native tests
+ run: cargo +1.95.0 test --workspace --locked --target ${{ matrix.target }}
+ - name: Release build
+ run: cargo +1.95.0 build --release --locked --target ${{ matrix.target }}
+ - name: Native executable smoke test
+ run: ./target/${{ matrix.target }}/release/tar-vault-sync${{ matrix.suffix }} --version
+ - name: Package
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - name: Build archive
+ run: python scripts/package-release.py --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
- name: ${{ matrix.artifact }}
- path: ${{ matrix.executable }}
+ name: tar-vault-sync-${{ matrix.platform }}-${{ matrix.arch }}
+ path: dist/*
if-no-files-found: error
+ retention-days: 30
+
+ verify-artifacts:
+ needs: desktop
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: tar-vault-sync-*
+ merge-multiple: true
+ path: dist
+ - name: Verify release assets
+ run: python scripts/verify-release.py dist
+
+ release:
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: verify-artifacts
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check tag matches package version
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: python -c 'import os,tomllib; assert os.environ["RELEASE_TAG"] == "v" + tomllib.load(open("Cargo.toml", "rb"))["package"]["version"]'
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: tar-vault-sync-*
+ merge-multiple: true
+ path: dist
+ - name: Verify all six archives again
+ run: python scripts/verify-release.py dist
+ - name: Publish verified preview
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: gh release create "$RELEASE_TAG" dist/* --verify-tag --prerelease --title "TAR Vault Sync $RELEASE_TAG" --notes-file docs/release-notes.md
diff --git a/.openai/hosting.json b/.openai/hosting.json
new file mode 100644
index 0000000..608b256
--- /dev/null
+++ b/.openai/hosting.json
@@ -0,0 +1,4 @@
+{
+ "project_id": "appgprj_6aafc60b3dd88191913e33ee67ee55ea",
+ "static": { "directory": "docs" }
+}
diff --git a/Cargo.lock b/Cargo.lock
index 7cd6e66..0b978c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -34,6 +34,17 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -134,6 +145,123 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.5",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.5",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -152,6 +280,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -212,6 +346,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.5.1"
@@ -221,6 +364,19 @@ dependencies = [
"objc2 0.5.2",
]
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -247,6 +403,12 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -310,6 +472,15 @@ dependencies = [
"wayland-client",
]
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.4.7"
@@ -426,6 +597,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -439,9 +620,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"core-graphics-types",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -452,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"libc",
]
@@ -503,12 +684,51 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dbus-secret-service"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
+dependencies = [
+ "aes",
+ "block-padding",
+ "cbc",
+ "dbus",
+ "fastrand",
+ "hkdf",
+ "num",
+ "once_cell",
+ "openssl",
+ "sha2",
+ "zeroize",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -704,6 +924,33 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "epaint"
version = "0.31.1"
@@ -750,6 +997,26 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -794,6 +1061,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
+]
+
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -801,7 +1077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
]
[[package]]
@@ -815,6 +1091,12 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -846,6 +1128,42 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -859,6 +1177,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
+ "futures-macro",
+ "futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@@ -1090,12 +1410,52 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
[[package]]
name = "hexf-parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -1230,6 +1590,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
+ "block-padding",
"generic-array",
]
@@ -1318,6 +1679,23 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "dbus-secret-service",
+ "log",
+ "openssl",
+ "secret-service",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -1341,6 +1719,16 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "libloading"
version = "0.8.9"
@@ -1444,7 +1832,7 @@ dependencies = [
"bitflags 2.13.2",
"block",
"core-graphics-types",
- "foreign-types",
+ "foreign-types 0.5.0",
"log",
"objc",
"paste",
@@ -1552,12 +1940,88 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1857,29 +2321,76 @@ dependencies = [
]
[[package]]
-name = "objc2-user-notifications"
-version = "0.2.2"
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
- "bitflags 2.13.2",
- "block2",
- "objc2 0.5.2",
- "objc2-core-location",
- "objc2-foundation 0.2.2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
-name = "once_cell"
-version = "1.21.4"
+name = "openssl-src"
+version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
+dependencies = [
+ "cc",
+]
[[package]]
-name = "opaque-debug"
-version = "0.3.1"
+name = "openssl-sys"
+version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
[[package]]
name = "orbclient"
@@ -1900,6 +2411,16 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -1909,6 +2430,12 @@ dependencies = [
"ttf-parser",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1981,6 +2508,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2184,6 +2722,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rowan"
version = "0.16.1"
@@ -2264,6 +2816,41 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -2291,6 +2878,61 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "secret-service"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
+dependencies = [
+ "aes",
+ "cbc",
+ "futures-util",
+ "generic-array",
+ "hkdf",
+ "num",
+ "once_cell",
+ "rand",
+ "serde",
+ "sha2",
+ "zbus",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "semver"
version = "1.0.28"
@@ -2340,6 +2982,39 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2565,18 +3240,26 @@ dependencies = [
[[package]]
name = "tar-vault-sync"
-version = "0.1.0"
+version = "0.1.0-rc.1"
dependencies = [
"argon2",
+ "base64 0.22.1",
"chacha20poly1305",
+ "csv-core",
"eframe",
"fs2",
+ "keyring",
"rand",
"rpassword",
"serde",
"serde_json",
+ "sha2",
"tempfile",
"tokio",
+ "ureq",
+ "url",
+ "webbrowser",
+ "winit",
"yaml-edit",
"zeroize",
]
@@ -2739,14 +3422,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
[[package]]
name = "ttf-parser"
@@ -2769,6 +3467,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -2803,6 +3512,40 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+dependencies = [
+ "base64 0.23.1",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -2815,12 +3558,24 @@ dependencies = [
"serde",
]
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3078,6 +3833,15 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -3468,7 +4232,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
- "core-foundation",
+ "core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -3563,6 +4327,16 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+[[package]]
+name = "xdg-home"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "xkbcommon-dl"
version = "0.4.2"
@@ -3594,7 +4368,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79e531de3d80075e0a2dc8246ca46c9167211297f8b9d513130880c2ef98d0b8"
dependencies = [
- "base64",
+ "base64 0.22.1",
"rowan",
]
@@ -3621,6 +4395,62 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
+dependencies = [
+ "async-broadcast",
+ "async-process",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "hex",
+ "nix",
+ "ordered-stream",
+ "rand",
+ "serde",
+ "serde_repr",
+ "sha1",
+ "static_assertions",
+ "tracing",
+ "uds_windows",
+ "windows-sys 0.52.0",
+ "xdg-home",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
+dependencies = [
+ "serde",
+ "static_assertions",
+ "zvariant",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.57"
@@ -3667,6 +4497,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
[[package]]
name = "zerotrie"
@@ -3727,3 +4571,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+
+[[package]]
+name = "zvariant"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zvariant_derive",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 04ad7f7..cff6986 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tar-vault-sync"
-version = "0.1.0"
+version = "0.1.0-rc.1"
edition = "2021"
[dependencies]
@@ -16,3 +16,13 @@ rand = "0.8"
fs2 = "0.4"
eframe = { version = "=0.31.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
zeroize = "1"
+csv-core = "0.1"
+ureq = { version = "3.4", default-features = false, features = ["rustls"] }
+url = "2"
+base64 = "0.22"
+sha2 = "0.10"
+webbrowser = "1"
+keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust", "vendored"] }
+
+[target.'cfg(target_os = "linux")'.dev-dependencies]
+winit = { version = "0.30", default-features = false, features = ["x11"] }
diff --git a/README.md b/README.md
index 453e03a..93ee366 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,10 @@
# TAR Vault Sync
+[Powered by fmarslan.com](https://fmarslan.com/)
+
TAR Vault Sync is a planned local-first agent for keeping development secrets in sync across machines and project files. It is designed to read from Azure Key Vault, Google Secret Manager, or an optional encrypted vault stored in Google Drive or OneDrive, then apply the current values to configured local targets.
-**Status:** Roadmap phases 2–5 have a local encrypted vault, file and Docker renderers, a Git Credential Manager target, and a native Rust management window. This is not a packaged desktop release: platform builds, secure-store IPC authentication for the separate `agent` mode, and macOS/Linux integration checks remain open. See the [phase 2–5 QA record](docs/qa-phase2-5.md).
+**Status:** The English native desktop supports filesystem vaults, Azure text secrets, and encrypted OneDrive/Google Drive vaults. Windows live-account cloud tests passed. Tag builds publish release candidates only after native tests, builds, smoke checks, and package checksums pass for Windows, macOS, and Linux on AMD64 and ARM64. Production IPC authentication, signing, two-device acceptance, and macOS/Linux live-account acceptance remain open. See the [release notes](docs/release-notes.md) and [phase 2–5 QA record](docs/qa-phase2-5.md).
## Intended capabilities
@@ -21,7 +23,7 @@ The implementation is planned in Rust. Day-to-day development and core tests wil
Run `cargo test --workspace` for the core and phase 2–5 tests. On a machine with a graphical desktop and Rust 1.95 or newer, set `TAR_VAULT_SYNC_DIR` to the workspace root and run `cargo run -- desktop`. The native window manages local-vault creation/unlock/lock, entry rotation/removal, encrypted backup/recovery, typed bindings, sync/status, restart acknowledgement, disabled-binding reactivation, and redacted events. It uses no browser or local HTTP server. The window runs the scheduler while open; the separate `agent` mode remains available for background operation and still uses the development-only `TAR_VAULT_SYNC_TEST_TOKEN` IPC credential. Do not treat that mode as production authentication. CLI vault/config commands remain available; see the [development guide](docs/development.md).
-The window's Yönetim page can switch to an existing workspace root. Only one writer (`desktop` or `agent`) may own a workspace at a time. The [native build workflow](.github/workflows/desktop-build.yml) can produce Windows, macOS, and Linux binaries when run in GitHub Actions; the workflow has not yet been exercised on those runners.
+The Settings page can switch to an existing workspace root. Only one writer (`desktop` or `agent`) may own a workspace at a time. Branch CI archives are test artifacts; version tags publish explicitly labelled preview releases. Cloud sign-in alone opens the provider's browser authorization and a temporary loopback callback; the application UI remains native. Open the executable without arguments to start the desktop app. Without `TAR_VAULT_SYNC_DIR`, it uses the platform's per-user application-data folder. `--help` and `--version` work without creating a workspace.
## Documentation
diff --git a/assets/branding/README.md b/assets/branding/README.md
new file mode 100644
index 0000000..2e47d75
--- /dev/null
+++ b/assets/branding/README.md
@@ -0,0 +1,9 @@
+# TAR Vault Sync logo
+
+
+
+Approved corporate logo generated with the built-in image generation tool. Navy and teal interlocking bands express synchronization; the central shield expresses protection. The desktop application embeds this PNG for its sidebar and native window icon, with no network or external asset-file dependency. The sidebar uses a light tile to preserve contrast against its navy background. Platform-specific executable, installer, and macOS bundle icons are not yet configured.
+
+## Generation prompt
+
+Create a refined corporate application logo icon for TAR Vault Sync, a local-first enterprise secrets vault and secure synchronization desktop application. Deliver one standalone square icon, no wordmark, no letters or text, no presentation board or mockup. A distinctive simple geometric vault emblem with two interlocking angular bands suggesting secure synchronization, and a subtle central negative-space shield. Strong balanced silhouette, precise construction, sophisticated understated enterprise identity. Use only deep midnight navy #132030 and rich teal #0f766e, with optional very restrained lighter teal facet. Flat vector-like graphic, crisp edges, no gradients, no shadows, no 3D, no tiny details, no generic padlock or circular arrows. Designed to remain recognizable at 32 pixels. Centered with generous clear margins on a genuinely transparent background. Premium contemporary cybersecurity software branding.
diff --git a/assets/branding/tar-vault-sync-mark-v1.png b/assets/branding/tar-vault-sync-mark-v1.png
new file mode 100644
index 0000000..ae57e69
Binary files /dev/null and b/assets/branding/tar-vault-sync-mark-v1.png differ
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..45ec6db
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+tarvault.tarsolution.com
diff --git a/docs/assets/activity.png b/docs/assets/activity.png
new file mode 100644
index 0000000..5118dbf
Binary files /dev/null and b/docs/assets/activity.png differ
diff --git a/docs/assets/analytics.js b/docs/assets/analytics.js
new file mode 100644
index 0000000..3a0ca57
--- /dev/null
+++ b/docs/assets/analytics.js
@@ -0,0 +1,74 @@
+/* Website analytics only. Never loaded by the native vault application. */
+(() => {
+ "use strict";
+ if (location.hostname !== "tarvault.tarsolution.com") return;
+ const measurementId = "G-MG2LD1T304";
+ const storageKey = "tarvault-analytics-consent";
+ let started = false;
+ function start() {
+ if (started) return;
+ started = true;
+ window.dataLayer = window.dataLayer || [];
+ window.gtag = function () { window.dataLayer.push(arguments); };
+ window.gtag("consent", "default", {
+ analytics_storage: "granted", ad_storage: "denied",
+ ad_user_data: "denied", ad_personalization: "denied"
+ });
+ window.gtag("js", new Date());
+ window.gtag("config", measurementId, {
+ page_location: location.origin + location.pathname,
+ page_referrer: "",
+ cookie_domain: "tarvault.tarsolution.com",
+ allow_google_signals: false,
+ allow_ad_personalization_signals: false
+ });
+ const tag = document.createElement("script");
+ tag.async = true;
+ tag.src = "https://www.googletagmanager.com/gtag/js?id=" + measurementId;
+ document.head.append(tag);
+ }
+ let choice;
+ try { choice = localStorage.getItem(storageKey); } catch (_) { /* Storage may be blocked. */ }
+ if (choice === "accepted") start();
+ const panel = document.createElement("section");
+ panel.setAttribute("aria-label", "Website analytics preference");
+ panel.style.cssText = "position:fixed;bottom:16px;right:16px;z-index:1000;max-width:360px;padding:16px;background:#fff;color:#132030;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 4px 20px #13203020;font:14px/1.5 system-ui";
+ const message = document.createElement("p");
+ message.textContent = "Allow Google Analytics cookies to measure visits to this documentation site? Vault contents are never included.";
+ panel.append(message);
+ const preference = document.createElement("button");
+ preference.type = "button";
+ preference.textContent = "Analytics preferences";
+ preference.style.cssText = "position:fixed;bottom:8px;right:8px;z-index:999;background:#fff;color:#132030;border:1px solid #cbd5e1;border-radius:6px;padding:6px 10px;font:12px system-ui;cursor:pointer";
+ preference.addEventListener("click", () => { panel.hidden = false; preference.hidden = true; });
+ for (const [label, value] of [["Allow analytics", "accepted"], ["Decline", "declined"]]) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = label;
+ button.style.cssText = "margin-right:8px;padding:8px 12px;border:1px solid #0f766e;border-radius:6px;background:#fff;color:#0f766e;cursor:pointer";
+ button.addEventListener("click", () => {
+ try { localStorage.setItem(storageKey, value); } catch (_) { /* Respect the choice for this page. */ }
+ window["ga-disable-" + measurementId] = value !== "accepted";
+ if (value === "accepted") {
+ if (started) window.gtag("consent", "update", { analytics_storage: "granted" });
+ start();
+ } else {
+ if (window.gtag) window.gtag("consent", "update", { analytics_storage: "denied" });
+ for (const cookie of document.cookie.split(";")) {
+ const name = cookie.split("=")[0].trim();
+ if (name === "_ga" || name.startsWith("_ga_")) {
+ for (const domain of ["", "; domain=tarvault.tarsolution.com"]) {
+ document.cookie = name + "=; Max-Age=0; path=/" + domain;
+ }
+ }
+ }
+ }
+ panel.hidden = true;
+ preference.hidden = false;
+ });
+ panel.append(button);
+ }
+ panel.hidden = choice === "accepted" || choice === "declined";
+ preference.hidden = !panel.hidden;
+ document.body.append(panel, preference);
+})();
diff --git a/docs/assets/bindings.png b/docs/assets/bindings.png
new file mode 100644
index 0000000..e6143ce
Binary files /dev/null and b/docs/assets/bindings.png differ
diff --git a/docs/assets/mark.png b/docs/assets/mark.png
new file mode 100644
index 0000000..ae57e69
Binary files /dev/null and b/docs/assets/mark.png differ
diff --git a/docs/assets/overview.png b/docs/assets/overview.png
new file mode 100644
index 0000000..5323926
Binary files /dev/null and b/docs/assets/overview.png differ
diff --git a/docs/assets/settings.png b/docs/assets/settings.png
new file mode 100644
index 0000000..f5abae0
Binary files /dev/null and b/docs/assets/settings.png differ
diff --git a/docs/assets/sources.png b/docs/assets/sources.png
new file mode 100644
index 0000000..d7e9c72
Binary files /dev/null and b/docs/assets/sources.png differ
diff --git a/docs/assets/vault.png b/docs/assets/vault.png
new file mode 100644
index 0000000..1303c64
Binary files /dev/null and b/docs/assets/vault.png differ
diff --git a/docs/azure-key-vault.md b/docs/azure-key-vault.md
new file mode 100644
index 0000000..aca82f2
--- /dev/null
+++ b/docs/azure-key-vault.md
@@ -0,0 +1,32 @@
+# Azure Key Vault development connector
+
+The native desktop binding editor supports read-only **Azure public-cloud text secrets**. Select Azure Key Vault, enter the vault name and secret name, and configure an authorized local target. JSON/binary conversion, sovereign clouds, embedded sign-in, and the independent CLI background agent are not supported by this connector yet. Keep the desktop open for scheduled Azure sync.
+
+## Authentication and scope
+
+Install the official Azure CLI and sign in to the intended tenant using `az login` before opening the application. Azure CLI manages its own credential cache. The app requests a short-lived token for `https://vault.azure.net`; it does not store credentials in its configuration, logs, or state. It does not create accounts, resources, secrets, or role assignments. Grant only the required `secrets/list` and `secrets/get` access through your administrator.
+
+Only vault/secret references and schedules enter shared configuration. Secret values are fetched directly over certificate-validated HTTPS, never by `az keyvault secret show`. CLI stderr, HTTP error bodies, tokens, and payloads are not displayed or logged. Network requests reject redirects; pagination stays on the same vault and secret. Credential acquisition is bounded to 20 seconds, HTTP requests to 15 seconds, response bodies to 256 KiB, and metadata traversal to 100 pages.
+
+## Version contract
+
+1. Read all version metadata pages without requesting values.
+2. Select the most recently created version. Equal creation timestamps with different newest versions are rejected as ambiguous, not silently ordered. A disabled, expired, or not-yet-valid newest version fails closed; older versions are not substituted.
+3. Skip value retrieval when the checked version is already applied.
+4. Fetch exactly the checked version, validate its returned identity and validity, then use the existing atomic target writer. Failed reads/writes preserve the existing target and applied version.
+
+Reference: Microsoft's [metadata-only version API](https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret-versions/get-secret-versions) and [version-specific value API](https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret/get-secret). API version: `2025-07-01`.
+
+## Validation
+
+Ordinary tests use synthetic responses and never contact Azure. They cover metadata pagination, unchanged-version suppression, rotation, authorization failure, wrong returned versions, invalid names, cross-host continuation links, expiry, and redacted persistent state/events. Desktop-controller tests cover saving, reopening, and editing Azure bindings without requiring a local vault.
+
+The opt-in `azure::tests::live_azure_rotation_and_cleanup` test requires explicit spending/write approval. It creates only a unique `tarvaultsync-test-…` secret, checks two versions through the actual connector and target writer, verifies unchanged-version suppression and redaction, then soft-deletes that secret and verifies failed reads preserve the target. It never purges deleted secrets or reads existing secret values. It caps connector GETs at 20; creation, preflight, and cleanup add fewer than 10 operations. Set `TAR_VAULT_AZURE_LIVE_APPROVED=yes`, `TAR_VAULT_AZURE_TEST_VAULT`, and `TAR_VAULT_AZURE_TEST_SECRET`, then explicitly select the ignored test. It is excluded from routine CI.
+
+### Real-account result — 2026-09-20
+
+Passed on the Windows host using the Windows GNU test executable compiled in the Linux container and an existing, explicitly approved Azure vault. The complete successful run used 11 Key Vault requests: creation, first apply, metadata-only unchanged check, rotation/apply, soft-delete, bounded deletion convergence, and target preservation. Both synthetic secrets created during validation were soft-deleted; neither was purged. Existing secrets and access policies were not modified.
+
+Two earlier checks exposed test assumptions: a nonexistent secret can return an empty version list instead of HTTP 404, and a successful delete may remain briefly visible in version metadata. The test now accepts only an empty/absent preflight and waits for bounded post-delete convergence. The connector did not read unrelated secret values or overwrite a target after source failure.
+
+Local validation: Linux 39 unit tests plus one CLI test, Windows 39 unit tests, Clippy with warnings denied, and the packaging test passed. The live test is additional to those totals. Native macOS/Linux cloud authentication, embedded sign-in, independent CLI-agent support, and interactive UI acceptance remain open release gates. This evidence is not a release approval or an all-provider completion claim.
diff --git a/docs/browser-import.md b/docs/browser-import.md
new file mode 100644
index 0000000..d095aee
--- /dev/null
+++ b/docs/browser-import.md
@@ -0,0 +1,66 @@
+# Browser password import: scope and acceptance
+
+## Supported scope
+
+Edge and Chrome document desktop CSV export/import flows. TAR Vault Sync uses a
+user-selected export only; it never opens profile databases, extracts browser
+encryption keys, or reads the user's signed-in browser session. No supported
+third-party password-store write API has been verified for this project, so
+continuous and bidirectional browser synchronization remain disabled.
+
+- [Microsoft: export passwords in Edge](https://support.microsoft.com/en-us/edge/export-passwords-in-microsoft-edge)
+- [Google: manage passwords in Chrome](https://support.google.com/chrome/answer/95606?hl=en)
+- [Google: password CSV column requirements](https://support.google.com/accounts/answer/10500247?hl=en)
+
+## Native desktop flow
+
+Unlock the local vault. In **Vault → Import browser passwords**, enter the path
+to a CSV you exported yourself and a unique non-secret prefix. Approve the
+plaintext-file warning and select **Import CSV into vault**. Consent is cleared
+after each attempt. Nothing is imported automatically on startup or by polling.
+
+Required CSV headers are `url`, `username`, `password`; optional headers are
+`name` and `note`/`notes`. UTF-8 with an optional BOM, quoted delimiters, escaped
+quotes, and multiline fields are supported. Unknown/duplicate headers, malformed
+quotes, inconsistent column counts, empty URLs/passwords, files over 8 MiB, and
+imports over 1,000 records are rejected. Empty usernames are allowed.
+
+Each row becomes one encrypted JSON entry named `prefix-0001`, `prefix-0002`, etc.
+URL, username, password, name and notes remain inside that encrypted entry, not
+in entry IDs, configuration, logs or status. Imported entries contain the entire
+credential record; they are not automatically mapped to password-only targets.
+
+The whole batch is committed in one encrypted vault replacement. Existing IDs
+cause rejection of the entire batch. No credential comparison, automatic merge,
+deduplication or deletion occurs. Reimport requires an explicitly different
+prefix, or deliberate removal of previous entries. Duplicate rows within a file
+are retained as separate entries. The plaintext source CSV is not copied or
+automatically deleted: the user must remove it when no longer needed. Secure
+erasure cannot be guaranteed on SSDs, backups, or synchronized folders.
+
+## Verification status
+
+Implementation and synthetic regression tests are in development. Actual Edge
+and Chrome export-to-vault UI acceptance is still pending on Windows, macOS and
+Linux. Unit tests and CI do not close these real-environment release gates.
+No real user password export is required or authorized as a test fixture.
+
+### Manual UI acceptance (isolated test workspace)
+
+Use a new empty workspace and synthetic credentials only. Do not export your
+everyday browser profile for testing. First validate the supplied CSV shape;
+then repeat using an export from a separate browser test profile containing only
+synthetic entries. Record OS, architecture, browser version, application commit,
+and each outcome. Never attach passwords, CSV contents, or vault passphrases.
+
+1. In the desktop app, create a test vault with a test-only passphrase.
+2. Enter the synthetic CSV path and prefix `browser-test`. With consent unchecked,
+ verify the import button is disabled. With the vault locked it must also be disabled.
+3. Unlock, approve consent, and import. Verify the success count, cleared consent,
+ and generated JSON entry IDs. No credential values should appear in messages.
+4. Re-enter the same file and prefix and explicitly approve again. Verify rejection,
+ unchanged entry count, and no silently overwritten data.
+5. Try a malformed CSV and verify no partial entries appear. Lock, close, reopen,
+ and unlock the app; previously imported IDs must remain available.
+6. Confirm the original CSV still exists. Remove synthetic test data when finished;
+ do not interpret deleting a CSV as guaranteed secure erasure.
diff --git a/docs/catalog-maintenance.md b/docs/catalog-maintenance.md
new file mode 100644
index 0000000..3fb1874
--- /dev/null
+++ b/docs/catalog-maintenance.md
@@ -0,0 +1,25 @@
+# Release and contributor catalogue
+
+The documentation is static, including `releases.html` and `credits.html`.
+Refresh public GitHub metadata with:
+
+```sh
+python scripts/update-docs-catalog.py
+python -m unittest discover -s tests
+```
+
+Commit the two generated HTML pages and push the documentation branch to
+publish them through the existing GitHub Pages deployment. Run this refresh
+after publishing, editing or deleting a release. It can also run during a
+future release pipeline before the documentation deployment; automatic
+release-triggered refresh is not configured yet.
+
+The latest section selects the most recently published non-draft,
+non-prerelease release. The archive contains the remaining published
+releases, newest first, with pre-releases labelled. Download links point to
+the authoritative GitHub release page. No release is created by this script.
+
+Contributor metadata comes from GitHub's public contributor list and public
+profiles. Bots appear separately. GitHub may cache this list and may omit
+non-code or unattributed contributions. No email addresses or credentials are
+stored. API failures stop generation rather than producing an empty catalogue.
diff --git a/docs/credits.html b/docs/credits.html
new file mode 100644
index 0000000..f6d1bfd
--- /dev/null
+++ b/docs/credits.html
@@ -0,0 +1,18 @@
+
+
TAR Vault Sync · Credits & contributors
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+TAR VAULT SYNC
Credits & contributors A project of the TAR Solution organization , developed by fmarslan.com .
Contributors Public GitHub commit contributors, using public profile names where available. This list does not imply ownership and may omit non-code or unattributed contributions.
GitHub metadata snapshot: 2026-09-20 10:47 UTC. Release and contributor data are static.
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..d13e5ea
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ TAR Vault Sync · Desktop guide
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+NATIVE DESKTOP · LOCAL-FIRST
Your vaults. Your destinations. Manage encrypted source vaults and distribute selected secrets to explicitly configured local targets. No browser-hosted management UI.
Development preview. This guide describes the development branch, not a production release. Cloud account registration is not evidence of a working connector. Read the status table before using a feature.
+Start the desktop app Use a build for your operating system and architecture. Windows opens tar-vault-sync.exe; macOS opens TAR Vault Sync.app; Linux runs tar-vault-sync in a graphical session. Launching without arguments opens the native desktop window. The website you are reading is documentation only. Open Sources to add a source, or Vault to create the default workspace vault. Open Bindings to connect a source entry to a target. Start with synthetic data and an isolated workspace. Keep the desktop window open for the current desktop scheduler. Workspace locations Windows: %LOCALAPPDATA%/tar-vault-sync macOS: ~/Library/Application Support/tar-vault-sync Linux: ${XDG_DATA_HOME:-~/.local/share}/tar-vault-sync Set TAR_VAULT_SYNC_DIR to select an existing workspace. Target files must remain inside that workspace; file-system source vaults may be elsewhere.
Actual native UI rendered in an isolated Linux documentation fixture. No real account or secret is loaded. Click an image for full resolution.
+HONEST CAPABILITIES
What is available? Capability Current status Important limit
+File System source vaults Implemented in development Multiple encrypted vault paths; each must be unlocked separately. Azure Key Vault Limited connector; live Windows synthetic test passed Public Azure, text secrets, existing Azure CLI sign-in; desktop scheduler only. OneDrive source vaults Not implemented yet OAuth application registration exists; account connection, encrypted transport and conflict tests remain. Google Drive source vaults Not implemented yet Desktop OAuth client exists; native authorization, secure credential storage and sync tests remain. AWS / Google Secret Manager Outside the current implementation scope Not the same as Google Drive. File / structured / Docker / Git targets Development implementations Docker writes signal restart required; containers are not restarted automatically. Background service controls in UI Not implemented yet Desktop scheduler is not an installed operating-system service. Cross-platform packages CI build/test matrix Build success is not native interactive acceptance, code signing or release approval.
+1. Add source connections A source is where secret entries are stored. A binding chooses the source, entry and destination. Adding a source never grants new Azure permissions or reads an existing secret automatically.
File System Open Sources → Add source connection → File System . Enter a unique Connection ID , for example team-files. The built-in ID local is reserved. Enter an absolute encrypted vault file path . Its parent directory must already exist. Relative paths, parent traversal and file symlinks are rejected. Select Add connection , then Manage vault . Create a new vault or unlock an existing compatible vault. Select Create binding on that source to preselect it in the binding editor. Connection references are saved in local/sources.json, never with passphrases or secret values. Removing a connection leaves its encrypted file intact. Connections referenced by bindings cannot be removed. Concurrent catalogue changes are rejected instead of overwritten.
Azure Key Vault Sign in to the intended Azure tenant using the official Azure CLI. Your administrator must already grant the required secrets/list and secrets/get access. In Sources , choose Azure Key Vault and enter the existing vault name, not a URL. Create a binding and enter the secret name. Only text secrets in public Azure are currently supported. Use Sync now and inspect the binding outcome. An unchanged version does not fetch the value again. Do not paste Azure access tokens into settings. The app obtains a short-lived token through the existing CLI session and retrieves values directly over HTTPS.
Example source references only; no cloud requests are made by this documentation fixture.
+2. Manage an encrypted vault Check Selected source before each operation. Use Sources → Manage vault to switch vaults. Enter a passphrase and select Create vault for a new file, or Unlock vault for an existing file. The app clears the passphrase input after the operation. Add an entry with an explicit secret type. Text, JSON, binary data, certificates and private keys use the encrypted local-vault format. Editing an entry creates a new version. Bindings detect the new version on the next check. Use Lock vault when finished. Vaults also lock after 15 minutes of inactivity; scheduled reads count as activity. Backup and recovery Use an encrypted backup path you control. Recovery requires the correct passphrase and a new destination; it does not overwrite an existing vault. Back up the passphrase separately in a trusted password manager. Losing the passphrase can make encrypted data unrecoverable.
Browser CSV import Import is explicit-consent and one-way. Export the CSV yourself, unlock the selected vault, choose the file and a unique import prefix, then approve the plaintext warning. Each record becomes encrypted JSON; existing entries are not overwritten. Limits: 8 MiB and 1,000 records. The original plaintext CSV is not deleted automatically. Continuous Edge/Chrome password-manager sync is not available.
The fixture keeps the vault locked and secret fields empty.
+3. Configure distribution bindings Open Bindings → New binding or select Create binding on a source card. Enter a unique Binding ID . Choose the source type and connection, then the entry ID or Azure secret name. Select the matching Secret type ; Azure currently fixes this to Text. Choose a destination type and an absolute target path inside the workspace. Set the field key when the target is structured. Set the check interval and missing-target policy, then save. Select Sync now . Check Activity and the destination application; a successful write alone does not prove the consumer has reloaded its configuration. Target Configuration Whole file Replaces the authorized file with exact payload bytes. .env / Properties Select a variable/property key; unrelated fields are preserved where supported. JSON / YAML Use the supported field pointer, such as /service/token. Docker Compose Choose the service/environment mapping. A changed input produces a restart notice. Docker env_file Specify the env file, Compose file and service reference. Git Credential Manager Configure HTTPS host, optional repository path and username. Requires GCM; not plaintext Git credential storage.
Missing-target policies Alert: report a missing target without recreating it.Recreate: explicitly allow the target file to be created again.Disable binding: stop that binding until you explicitly enable it.Unsubmitted example binding. No actual secret or destination file is created.
+4. Inspect results and recover Applied means a changed version was written successfully. Unchanged skips payload retrieval. Disabled requires explicit enablement. Restart required means the consumer needs your attention; acknowledging the notice does not restart Docker. Failed requires checking source access, vault lock state, target scope or format.
Events contain redacted metadata, not secret values. A failed render leaves the previous target unchanged. Version state advances only after successful application. Never paste secrets into an issue or diagnostics report.
No live-account events are included.
+5. Workspace and agent lifecycle The current UI does not yet start, stop, restart or install a background operating-system service. The desktop owns its scheduler while the window is open. Closing the window stops that desktop scheduler. Do not rely on it for unattended delivery.
The separate CLI agent remains a development interface with test IPC authentication and a limited source configuration. It is not a secure production replacement for the requested service controls. Secure IPC, account-aware credential handling, restart/reconnect behavior and native lifecycle acceptance are required before this feature can be marked complete.
In Settings , inspect the workspace or open another workspace. Source vault paths may live outside the workspace; target files may not.
Settings display the implemented lifecycle, without implying installed-service support.
+Security boundaries Local vault payloads are encrypted using Argon2id-derived keys and XChaCha20-Poly1305 authenticated encryption. Version metadata is checked before secret payload retrieval. Plaintext is transient, except when written to a target you explicitly configure. Protect those destination files separately. Configuration and state store references, versions and redacted outcomes—not secret values, passphrases or OAuth tokens. Files are rendered through temporary sibling files and atomic replacement. Cloud vault transport must authenticate encrypted data and reject conflicting revisions before it is considered complete. This preview is not code-signed/notarized, audited or approved for production secrets. CI checksums detect changed archive bytes; they are not publisher signatures.
+Troubleshooting Source connection cannot be added Use a unique safe ID and an absolute file path. Ensure the parent directory exists. Azure connection IDs must be valid vault names. A stale catalogue is deliberately rejected; reopen the app rather than overwriting someone else's change. Sync fails after reopening Vaults reopen locked. Unlock the exact source used by the binding. Azure requires a valid CLI session in the intended tenant. Target is rejected The target must be inside the configured workspace and match the chosen secret type. Check the parent folder and structured-field syntax. Source cannot be removed Remove or update its bindings first, and wait for an active manual sync to finish. Removing a connection does not delete the vault. Drive login is missing The OAuth registrations are prepared, but the desktop connectors are still pending. Do not use broader scopes or paste credentials into config files as a workaround.
+
+
diff --git a/docs/release-notes.md b/docs/release-notes.md
new file mode 100644
index 0000000..7349c40
--- /dev/null
+++ b/docs/release-notes.md
@@ -0,0 +1,46 @@
+# TAR Vault Sync desktop preview
+
+Native desktop builds for Windows, macOS, and Linux, each in AMD64/x64 and ARM64.
+This release candidate includes filesystem, Azure Key Vault, OneDrive, and Google Drive sources.
+It is not a claim that every roadmap connector or production-hardening task is complete.
+
+## Included
+
+- English desktop interface for vault access, bindings, activity, and workspace settings.
+- Encrypted local vault, locking, secret rotation, encrypted backup and recovery.
+- File, dotenv, JSON, YAML, properties, Docker input, and Git Credential Manager targets.
+- Scheduled and manual sync with redacted events and explicit restart notices.
+- Native OAuth/PKCE setup for OneDrive and Google Drive encrypted vaults in a selected folder.
+- OS-protected OAuth credentials, revision-pinned Google downloads, and conditional cloud writes.
+- Explicit-consent browser CSV import and Azure public-cloud text-secret sources.
+
+## Start
+
+Extract the archive. On Windows, open `tar-vault-sync.exe`; on macOS, open `TAR Vault Sync.app`;
+on Linux, run `./tar-vault-sync` from the extracted directory. No arguments opens the desktop app.
+`--help` lists command-line modes; `--version` prints the build version.
+
+The default workspace is `%LOCALAPPDATA%/tar-vault-sync` on Windows,
+`~/Library/Application Support/tar-vault-sync` on macOS, or
+`${XDG_DATA_HOME:-~/.local/share}/tar-vault-sync` on Linux.
+Set `TAR_VAULT_SYNC_DIR` to use an existing workspace. File targets must be inside that workspace.
+
+Linux packages target Ubuntu 24.04 or a compatible glibc system and require a graphical session,
+OpenGL/EGL, X11 or Wayland, and desktop libraries (`libx11-6`, `libxkbcommon0`, `libegl1`, `libgl1`).
+Git credential targets require Git Credential Manager to be installed and configured separately.
+
+## Verification and limits
+
+CI runs native tests, compiles the release binary, and executes `--version` on all six runner architectures.
+Each archive has a SHA-256 checksum in `SHA256SUMS`. Checksums detect corruption; they are not publisher signatures.
+These archives are not code-signed or notarized and are not installers.
+
+Windows real-account OneDrive and Google Drive tests passed: encrypted create/read/update,
+stale-write rejection, lock/reopen, and test-file cleanup. Live OAuth acceptance on macOS
+and Linux remains unverified; CI native tests are not a substitute for those account tests.
+Linux OAuth storage requires an unlocked Secret Service keyring. Google setup requires
+the configured desktop OAuth client's secret in the masked field; it is never bundled.
+Azure requires an existing Azure CLI sign-in; native embedded sign-in remains open.
+AWS/Google secret-manager sources and a Git-backed vault remain roadmap work.
+Signed distribution is not included.
+The independent CLI agent still uses development IPC authentication; the desktop app runs its scheduler while open.
diff --git a/docs/releases.html b/docs/releases.html
new file mode 100644
index 0000000..874d846
--- /dev/null
+++ b/docs/releases.html
@@ -0,0 +1,18 @@
+
+TAR Vault Sync · Releases
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Published GitHub releases only. CI artifacts are not releases.
Latest stable release No stable release has been published yet.
Release archive Newest first; pre-releases are explicitly labelled.
No archived releases yet.
View the live release catalogue on GitHub
GitHub metadata snapshot: 2026-09-20 10:47 UTC. Release and contributor data are static.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 078eb70..1bf0731 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -2,7 +2,7 @@
TAR Vault Sync will be built in the following order. Each phase should deliver a usable vertical slice with focused tests and documentation. Connector and sync modules own their configuration UI; the core provides only the contracts and host surface for those screens.
-Phase 1 is complete for the fake-module test host described below. Phases 2–5 have development implementations; [phase 2–5 QA](qa-phase2-5.md) records verified behavior and remaining native release gates. Later phases remain planned. [Requirements](requirements.md) define the product and security constraints.
+Phase 1 is complete for the fake-module test host described below. Phases 2–9 and the limited Azure slice in phase 10 have development implementations; [phase 2–5 QA](qa-phase2-5.md) records earlier verified behavior and native release gates. Phases 11–13 remain deferred or planned. [Requirements](requirements.md) define the product and security constraints.
## 1. Core infrastructure
@@ -27,7 +27,7 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 2. Vault entry and local vault store
-**Status:** Encrypted store, CLI flow, and native desktop vault entry screen implemented; platform packaging and visual QA remain open.
+**Status:** Encrypted store, CLI flow, and native desktop vault entry screen implemented. The Sources page supports multiple file-system vault references, source-specific binding selection, independent unlock state and non-destructive connection removal. Native release acceptance remains open.
- Build the vault unlock/entry screen and local encrypted vault store.
- Define the store-connection UI contract used by local and future cloud stores.
@@ -66,6 +66,8 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 6. Microsoft password manager import and sync
+**Status:** Import-only scope selected; native CSV-to-encrypted-vault implementation is in development. Real Edge export/UI acceptance remains open. See [browser import](browser-import.md).
+
- Investigate supported Microsoft Edge/password-manager import and write interfaces and their platform restrictions.
- Implement user-approved import first. Add ongoing sync only if a supported API permits it safely.
- Define conflict resolution and deletion rules before enabling two-way changes.
@@ -74,6 +76,8 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 7. Chrome password manager import and sync
+**Status:** Shares the explicit-consent CSV import implementation; continuous sync is disabled. Real Chrome export/UI acceptance remains open. See [browser import](browser-import.md).
+
- Investigate supported Chrome/Google Password Manager import and write interfaces.
- Follow the same explicit-consent, conflict, and deletion rules as phase 6; do not modify browser profile databases directly.
@@ -81,6 +85,8 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 8. OneDrive store connector
+**Status:** Native OAuth/PKCE, OS credential storage, selected-folder encrypted vaults, and conditional writes implemented. Windows real-account create/read/rotate/lock/reopen and stale-write rejection passed on 2026-09-20. Two physical-device and macOS/Linux live-account acceptance remain open.
+
- Store encrypted vault data in the user's OneDrive with revision-aware reads and writes.
- Detect concurrent edits and present conflicts rather than silently overwriting a vault.
@@ -88,12 +94,16 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 9. Google Drive store connector
+**Status:** Native OAuth/PKCE with limited Drive Picker access, OS credential storage, revision-pinned downloads, and conditional writes implemented. Windows real-account create/read/rotate/lock/reopen and stale-write rejection passed on 2026-09-20; synthetic files were trashed and temporary credentials removed. Two physical-device and macOS/Linux live-account acceptance remain open. Existing project-wide consent branding was not modified.
+
- Apply the same encrypted-vault, revision, and conflict contracts to Google Drive.
**Done when:** a paired device can read and update the encrypted vault through Google Drive without exposing plaintext to Drive.
## 10. Azure Key Vault connector
+**Status:** Development implementation for Azure public-cloud text secrets, using existing Azure CLI authentication and the native desktop binding editor. Live Windows validation passed on 2026-09-20 for rotation, metadata-only unchanged checks, redaction, soft-delete cleanup, and failed-source target preservation. Embedded sign-in, CLI-agent support, interactive UI acceptance, and macOS/Linux cloud acceptance remain open. See [Azure connector](azure-key-vault.md).
+
- Add authentication, metadata-only version checks, and secret-value retrieval on change.
- Map Azure secret versions and errors into the common store contract.
@@ -101,6 +111,8 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 11. AWS Secrets Manager connector
+**Current scope:** Deferred by user request; not part of the four-source delivery.
+
- Add authentication, version/stage checks, and value retrieval through AWS Secrets Manager.
- Confirm the intended service name before implementation; this phase interprets "AWS password manager" as AWS Secrets Manager.
@@ -108,6 +120,8 @@ Phase 1 is complete for the fake-module test host described below. Phases 2–5
## 12. Google Secret Manager connector
+**Current scope:** Deferred by user request. Google Drive is a separate, in-scope encrypted-vault source.
+
- Add Google Cloud Secret Manager authentication, version metadata checks, and value retrieval.
- Confirm the intended service name before implementation; this phase interprets "Google vault" as Google Cloud Secret Manager.
diff --git a/docs/robots.txt b/docs/robots.txt
new file mode 100644
index 0000000..d55b897
--- /dev/null
+++ b/docs/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://tarvault.tarsolution.com/sitemap.xml
diff --git a/docs/screenshots.md b/docs/screenshots.md
new file mode 100644
index 0000000..1032c0c
--- /dev/null
+++ b/docs/screenshots.md
@@ -0,0 +1,30 @@
+# Reproduce the documentation screenshots
+
+The six guide images are actual native egui screens rendered with an isolated,
+synthetic workspace. They contain no account credentials or secret values and
+are not evidence of cloud integration or Windows/macOS interactive acceptance.
+
+On Linux, install Xvfb, Mesa/OpenGL, `libxkbcommon-x11-0`, X11 runtime libraries
+and ImageMagick, then run:
+
+```sh
+TAR_VAULT_DOC_OUTPUT="$PWD/target/doc-captures" \
+ xvfb-run -a -s '-screen 0 1440x1800x24' \
+ cargo test --locked --lib desktop::tests::render_documentation_screens -- \
+ --ignored --exact
+for page in overview sources vault bindings activity settings; do
+ magick "target/doc-captures/$page.ppm" "docs/assets/$page.png"
+done
+python3 -m unittest discover -s tests -p 'test_*.py'
+```
+
+The capture test is ignored in normal unit runs because it needs a graphical
+backend. It renders the application directly; it never imports a browser
+profile, unlocks a real vault, or contacts a cloud provider. Review every PNG
+before committing. Do not publish screenshots from a real workspace.
+
+The static site is `docs/index.html`; no build framework is needed. Optional
+website analytics loads only after visitor consent. GitHub Pages publishes
+this directory. Its custom domain
+is `tarvault.tarsolution.com`. Publishing documentation does not publish an app
+release or satisfy any release acceptance gate.
diff --git a/docs/site.css b/docs/site.css
new file mode 100644
index 0000000..8cd7fde
--- /dev/null
+++ b/docs/site.css
@@ -0,0 +1,2 @@
+:root{color-scheme:light;--nav:#132030;--ink:#182739;--muted:#596b7f;--accent:#0f766e;--border:#e1e7ed;--canvas:#f5f7fa}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:24px}body{margin:0;font:16px/1.7 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--canvas);color:var(--ink)}aside{position:fixed;inset:0 auto 0 0;width:248px;background:var(--nav);padding:38px 24px;color:#a8bacb;overflow:auto}.brand{display:flex;align-items:center;gap:12px;color:white!important;text-decoration:none;font-size:27px;font-weight:750;line-height:1.2}.brand img{width:62px;height:62px;background:#f5f7fa;border-radius:12px;padding:5px}.brand small{display:block;font-size:10px;letter-spacing:2px;font-weight:600}.eyebrow{font-size:11px;font-weight:750;letter-spacing:2px;color:var(--accent)}aside .eyebrow{margin-top:45px;color:#8da4bb}nav a{display:block;padding:9px 10px;margin:3px 0;color:#c3d0dd;text-decoration:none;font-size:14px;border-radius:6px}nav a:hover,nav a:focus-visible{background:#24404c;color:white}.repo{display:block;margin-top:35px;color:#85d5c5;font-size:13px}main{max-width:1220px;margin-left:248px;padding:50px 64px}header{padding:30px 0 25px}h1{font-size:clamp(42px,6vw,72px);letter-spacing:-3px;line-height:1.05;margin:20px 0 26px}h2{font-size:30px;letter-spacing:-.8px;line-height:1.3;margin:0 0 20px}h3{font-size:20px;margin-top:32px}.lead{max-width:680px;font-size:20px;color:var(--muted)}.notice{background:#fff5df;border-left:4px solid #c28b1c;border-radius:0 9px 9px 0;padding:18px 22px;font-size:14px}section{padding:35px 0;border-bottom:1px solid var(--border)}p,li,dd{max-width:850px}li{padding:4px 0}a{color:var(--accent);text-underline-offset:3px}a:focus-visible,summary:focus-visible{outline:3px solid #df9b2a;outline-offset:3px}code{background:#eaf0f3;border:1px solid #dae3e9;border-radius:4px;padding:2px 5px;font-size:.86em;overflow-wrap:anywhere}figure{margin:30px 0}figure img{display:block;width:100%;height:auto;max-height:720px;object-fit:contain;object-position:top;background:#e9eef2;border:1px solid #cfd9e1;border-radius:10px;box-shadow:0 10px 28px #13203012}figcaption{font-size:12px;color:var(--muted);margin:10px 2px}.table-wrap{overflow-x:auto;background:white;border:1px solid var(--border);border-radius:9px}table{width:100%;border-collapse:collapse;min-width:550px;text-align:left;font-size:14px}th{background:#e8f2ef;color:#165a53;padding:15px}td{padding:15px;border-top:1px solid var(--border);vertical-align:top}details{background:white;padding:16px 22px;border:1px solid var(--border);border-radius:8px}summary{cursor:pointer;font-weight:650}dt{font-weight:700;margin-top:22px}dd{margin:5px 0 18px;color:var(--muted)}footer{padding:30px 0 5px;color:var(--muted);font-size:13px}@media(max-width:900px){aside{position:static;width:auto;padding:24px}aside .eyebrow{margin-top:20px}nav{display:flex;flex-wrap:wrap;gap:2px}nav a{padding:6px 10px}.repo{margin-top:12px}main{margin-left:0;padding:20px 24px}h1{letter-spacing:-2px}h2{font-size:26px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}@media print{aside{display:none}main{margin:0;padding:0}figure img{max-height:500px}section{break-inside:avoid}a{color:inherit}}
+:root aside{display:flex;flex-direction:column}.credits{margin-top:auto;padding-top:28px;font-size:12px;line-height:1.8}.credits a{display:block;color:#b9d7d6}.credits a:last-child{color:#85d5c5}.release-card{background:white;border:1px solid var(--border);border-radius:12px;padding:8px 24px 18px;margin:18px 0}.release-card h3{margin-top:16px}@media(max-width:900px){.credits{margin-top:18px}}
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
new file mode 100644
index 0000000..c526bec
--- /dev/null
+++ b/docs/sitemap.xml
@@ -0,0 +1,6 @@
+
+
+ https://tarvault.tarsolution.com/
+ https://tarvault.tarsolution.com/releases.html
+ https://tarvault.tarsolution.com/credits.html
+
diff --git a/scripts/package-release.py b/scripts/package-release.py
new file mode 100644
index 0000000..01d68f0
--- /dev/null
+++ b/scripts/package-release.py
@@ -0,0 +1,77 @@
+"""Package a native build without credentials, workspaces, or build caches."""
+
+import argparse
+import hashlib
+from pathlib import Path
+import plistlib
+import shutil
+import stat
+import tarfile
+import tempfile
+import tomllib
+import zipfile
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--platform", choices=("windows", "macos", "linux"), required=True)
+ parser.add_argument("--arch", choices=("amd64", "arm64"), required=True)
+ parser.add_argument("--target", required=True)
+ args = parser.parse_args()
+ version = tomllib.loads(Path("Cargo.toml").read_text(encoding="utf-8"))["package"]["version"]
+ name = f"tar-vault-sync-{version}-{args.platform}-{args.arch}"
+ binary_name = "tar-vault-sync.exe" if args.platform == "windows" else "tar-vault-sync"
+ binary = Path("target") / args.target / "release" / binary_name
+ if not binary.is_file():
+ raise SystemExit("Release binary is missing")
+ output = Path("dist")
+ output.mkdir(exist_ok=True)
+ with tempfile.TemporaryDirectory() as temporary:
+ package = Path(temporary) / name
+ package.mkdir()
+ if args.platform == "macos":
+ contents = package / "TAR Vault Sync.app" / "Contents"
+ executable = contents / "MacOS" / binary_name
+ executable.parent.mkdir(parents=True)
+ with (contents / "Info.plist").open("wb") as stream:
+ plistlib.dump({
+ "CFBundleName": "TAR Vault Sync",
+ "CFBundleDisplayName": "TAR Vault Sync",
+ "CFBundleIdentifier": "com.tarsolution.tarvaultsync",
+ "CFBundleExecutable": binary_name,
+ "CFBundlePackageType": "APPL",
+ "CFBundleShortVersionString": version,
+ "CFBundleVersion": version,
+ "NSHighResolutionCapable": True,
+ }, stream)
+ else:
+ executable = package / binary_name
+ shutil.copy2(binary, executable)
+ executable.chmod(0o755)
+ shutil.copy2("docs/release-notes.md", package / "README.md")
+ if args.platform == "linux":
+ archive = output / f"{name}.tar.gz"
+ def archive_mode(member):
+ member.mode = 0o755 if member.isdir() or member.name == f"{name}/{binary_name}" else 0o644
+ return member
+ with tarfile.open(archive, "w:gz") as stream:
+ stream.add(package, arcname=name, filter=archive_mode)
+ else:
+ archive = output / f"{name}.zip"
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as stream:
+ for path in sorted(package.rglob("*")):
+ if path.is_file():
+ info = zipfile.ZipInfo.from_file(path, path.relative_to(package.parent))
+ info.create_system = 3 # Unix modes, independent of the packaging host.
+ info.external_attr = (stat.S_IFREG | (0o755 if path == executable else 0o644)) << 16
+ info.compress_type = zipfile.ZIP_DEFLATED
+ with path.open("rb") as source, stream.open(info, "w") as destination:
+ shutil.copyfileobj(source, destination)
+ with archive.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ (output / f"{archive.name}.sha256").write_text(f"{digest} {archive.name}\n", encoding="ascii")
+ print(f"Packaged {archive.name} ({archive.stat().st_size} bytes)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/update-docs-catalog.py b/scripts/update-docs-catalog.py
new file mode 100644
index 0000000..f4aa155
--- /dev/null
+++ b/scripts/update-docs-catalog.py
@@ -0,0 +1,105 @@
+"""Generate static release and contributor pages from public GitHub metadata."""
+
+from datetime import datetime, timezone
+from html import escape
+import json
+from pathlib import Path
+from urllib.request import Request, urlopen
+
+REPO = "tarsolution/tarvaultsync"
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def fetch(path):
+ request = Request("https://api.github.com/" + path,
+ headers={"Accept": "application/vnd.github+json",
+ "User-Agent": "TAR-Vault-Sync-docs"})
+ with urlopen(request, timeout=30) as response:
+ return json.load(response)
+
+
+def listing(endpoint):
+ result = []
+ page = 1
+ while True:
+ batch = fetch(f"repos/{REPO}/{endpoint}?per_page=100&page={page}")
+ result.extend(batch)
+ if len(batch) < 100:
+ return result
+ page += 1
+
+
+def link(url, label):
+ if not url.startswith("https://github.com/"):
+ raise ValueError("Expected a public GitHub URL")
+ return f'{escape(label)} '
+
+
+def page(title, content, date):
+ return f'''
+TAR Vault Sync · {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{content}GitHub metadata snapshot: {date}. Release and contributor data are static.
+'''
+
+
+def release_card(release):
+ name = release.get("name") or release["tag_name"]
+ status = "Pre-release" if release.get("prerelease") else "Stable release"
+ return (f'{link(release["html_url"], name)} '
+ f'{status} · {escape(release["tag_name"])} · '
+ f'{escape(release["published_at"][:10])}
'
+ f'{link(release["html_url"], "Release notes, downloads & checksums on GitHub")}'
+ '
')
+
+
+def releases_content(releases):
+ published = sorted((r for r in releases if not r.get("draft") and r.get("published_at")),
+ key=lambda r: r["published_at"], reverse=True)
+ latest = next((r for r in published if not r.get("prerelease")), None)
+ content = 'Published GitHub releases only. CI artifacts are not releases.
Latest stable release '
+ content += release_card(latest) if latest else 'No stable release has been published yet.
'
+ content += 'Release archive Newest first; pre-releases are explicitly labelled.
'
+ archive = [r for r in published if r is not latest]
+ content += ''.join(map(release_card, archive)) if archive else 'No archived releases yet.
'
+ return content + f'{link(f"https://github.com/{REPO}/releases", "View the live release catalogue on GitHub")}
'
+
+
+def main():
+ # Fetch everything before replacing either page. Network failures fail the build.
+ releases = listing("releases")
+ contributors = listing("contributors")
+ people, bots = [], []
+ for contributor in contributors:
+ login = contributor["login"]
+ profile = fetch("users/" + login)
+ name = profile.get("name") or login
+ item = '' + link(contributor["html_url"], f'{name} (@{login})') + ' '
+ (bots if contributor.get("type") == "Bot" else people).append(item)
+ credits = 'A project of the TAR Solution organization , developed by fmarslan.com .
Contributors Public GitHub commit contributors, using public profile names where available. This list does not imply ownership and may omit non-code or unattributed contributions.
'
+ credits += '' if people else 'No public contributors listed yet.
'
+ credits += ' '
+ if bots:
+ credits += ''
+ date = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+ for filename, title, content in [("releases.html", "Releases", releases_content(releases)),
+ ("credits.html", "Credits & contributors", credits)]:
+ (ROOT / "docs" / filename).write_text(page(title, content, date), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/verify-release.py b/scripts/verify-release.py
new file mode 100644
index 0000000..b22a9ef
--- /dev/null
+++ b/scripts/verify-release.py
@@ -0,0 +1,31 @@
+"""Require the complete six-platform matrix and matching checksums before publishing."""
+
+import hashlib
+from pathlib import Path
+import sys
+
+
+def main():
+ folder = Path(sys.argv[1])
+ entries = []
+ for platform in ("windows", "macos", "linux"):
+ for arch in ("amd64", "arm64"):
+ extension = "tar.gz" if platform == "linux" else "zip"
+ matches = list(folder.glob(f"tar-vault-sync-*-{platform}-{arch}.{extension}"))
+ if len(matches) != 1:
+ raise SystemExit(f"Expected one {platform}/{arch} package")
+ archive = matches[0]
+ checksum = folder / f"{archive.name}.sha256"
+ expected = checksum.read_text(encoding="ascii").strip()
+ with archive.open("rb") as stream:
+ actual = hashlib.file_digest(stream, "sha256").hexdigest()
+ line = f"{actual} {archive.name}"
+ if expected != line:
+ raise SystemExit(f"Checksum mismatch: {archive.name}")
+ entries.append(line)
+ (folder / "SHA256SUMS").write_text("\n".join(sorted(entries)) + "\n", encoding="ascii")
+ print("All six packages and checksums verified")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/azure.rs b/src/azure.rs
new file mode 100644
index 0000000..09f31a4
--- /dev/null
+++ b/src/azure.rs
@@ -0,0 +1,749 @@
+//! Read-only Azure public-cloud secrets. Authentication belongs to Azure CLI;
+//! values travel directly over HTTPS, never through CLI output or its logs.
+
+use crate::core::{
+ ErrorCategory, SecretPayload, SecretType, SourceConnection, SourceRef, SourceVersion, StoreKind,
+};
+use serde::Deserialize;
+use std::{
+ io::Read,
+ process::{Command, Stdio},
+ sync::mpsc,
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+use url::Url;
+use zeroize::Zeroizing;
+
+const API_VERSION: &str = "2025-07-01";
+const MAX_RESPONSE: u64 = 256 * 1024;
+const MAX_PAGES: usize = 100;
+
+pub(crate) fn valid_source(source: &SourceRef) -> bool {
+ source.store == StoreKind::Azure
+ && (3..=24).contains(&source.connection.len())
+ && source.connection.as_bytes()[0].is_ascii_alphabetic()
+ && source
+ .connection
+ .as_bytes()
+ .last()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && !source.connection.contains("--")
+ && source
+ .connection
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-')
+ && (1..=127).contains(&source.entry.len())
+ && source
+ .entry
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-')
+}
+
+// Kept private: the test transport exercises the real parser and source contract
+// without granting tests network access or reading an Azure CLI credential cache.
+trait Transport: Send + Sync {
+ fn get(&self, url: &str) -> Result>, ErrorCategory>;
+}
+
+pub(crate) struct AzureSource {
+ transport: Box,
+}
+
+impl AzureSource {
+ pub(crate) fn new() -> Self {
+ Self {
+ transport: Box::new(HttpsTransport),
+ }
+ }
+}
+
+struct HttpsTransport;
+impl Transport for HttpsTransport {
+ fn get(&self, url: &str) -> Result>, ErrorCategory> {
+ let token = cli_token()?;
+ let token = std::str::from_utf8(&token)
+ .map_err(|_| ErrorCategory::Unauthorized)?
+ .trim();
+ if token.is_empty()
+ || token
+ .bytes()
+ .any(|b| b.is_ascii_whitespace() || b.is_ascii_control())
+ {
+ return Err(ErrorCategory::Unauthorized);
+ }
+ let authorization = Zeroizing::new(format!("Bearer {token}"));
+ let agent = ureq::Agent::config_builder()
+ .timeout_global(Some(Duration::from_secs(15)))
+ .max_redirects(0)
+ .https_only(true)
+ .build()
+ .new_agent();
+ let mut response = agent
+ .get(url)
+ .header("Authorization", authorization.as_str())
+ .header("Accept", "application/json")
+ .call()
+ .map_err(|e| match e {
+ ureq::Error::StatusCode(401 | 403) => ErrorCategory::Unauthorized,
+ _ => ErrorCategory::Source,
+ })?;
+ if response.status().as_u16() != 200 {
+ return Err(ErrorCategory::Source);
+ }
+ read_bounded(response.body_mut().as_reader(), MAX_RESPONSE)
+ }
+}
+
+fn read_bounded(reader: impl Read, limit: u64) -> Result>, ErrorCategory> {
+ let mut data = Zeroizing::new(Vec::new());
+ reader
+ .take(limit + 1)
+ .read_to_end(&mut data)
+ .map_err(|_| ErrorCategory::Source)?;
+ if data.len() as u64 > limit {
+ return Err(ErrorCategory::Source);
+ }
+ Ok(data)
+}
+
+fn cli_token() -> Result>, ErrorCategory> {
+ let mut command = Command::new(if cfg!(windows) { "az.cmd" } else { "az" });
+ command
+ .args([
+ "account",
+ "get-access-token",
+ "--resource",
+ "https://vault.azure.net",
+ "--query",
+ "accessToken",
+ "--output",
+ "tsv",
+ "--only-show-errors",
+ ])
+ .env("AZURE_CORE_COLLECT_TELEMETRY", "false")
+ .env("AZURE_CORE_LOG_LEVEL", "critical")
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ command.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ }
+ let mut child = command.spawn().map_err(|_| ErrorCategory::Unauthorized)?;
+ let stdout = child.stdout.take().ok_or(ErrorCategory::Unauthorized)?;
+ let (sender, receiver) = mpsc::sync_channel(1);
+ std::thread::spawn(move || {
+ let _ = sender.send(read_bounded(stdout, 16 * 1024));
+ });
+ let deadline = Instant::now() + Duration::from_secs(20);
+ let result = loop {
+ match child.try_wait() {
+ Ok(Some(status)) if status.success() => {
+ break receiver
+ .recv_timeout(deadline.saturating_duration_since(Instant::now()))
+ .map_err(|_| ErrorCategory::Unauthorized)
+ .and_then(|result| result.map_err(|_| ErrorCategory::Unauthorized));
+ }
+ Ok(Some(_)) | Err(_) => break Err(ErrorCategory::Unauthorized),
+ Ok(None) if Instant::now() >= deadline => break Err(ErrorCategory::Unauthorized),
+ Ok(None) => std::thread::sleep(Duration::from_millis(20)),
+ }
+ };
+ if result.is_err() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ result
+}
+
+#[derive(Deserialize)]
+struct Attributes {
+ created: u64,
+ enabled: bool,
+ exp: Option,
+ nbf: Option,
+}
+impl Attributes {
+ fn usable(&self, now: u64) -> bool {
+ self.enabled
+ && self.exp.is_none_or(|exp| now < exp)
+ && self.nbf.is_none_or(|nbf| now >= nbf)
+ }
+}
+#[derive(Deserialize)]
+struct VersionItem {
+ id: String,
+ attributes: Attributes,
+}
+#[derive(Deserialize)]
+struct VersionPage {
+ value: Vec,
+ #[serde(rename = "nextLink")]
+ next_link: Option,
+}
+#[derive(Deserialize)]
+struct SecretResponse {
+ id: String,
+ attributes: Attributes,
+ #[serde(deserialize_with = "secret_string")]
+ value: Zeroizing,
+}
+fn secret_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> {
+ String::deserialize(d).map(Zeroizing::new)
+}
+fn now() -> Result {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .map_err(|_| ErrorCategory::Source)
+}
+fn base_url(source: &SourceRef) -> Result {
+ if !valid_source(source) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ Ok(format!(
+ "https://{}.vault.azure.net/secrets/{}",
+ source.connection.to_ascii_lowercase(),
+ source.entry
+ ))
+}
+fn valid_version(version: &str) -> bool {
+ version.len() == 32 && version.bytes().all(|b| b.is_ascii_hexdigit())
+}
+fn version_from_id(base: &str, id: &str) -> Result {
+ let prefix = format!("{base}/");
+ let version = id
+ .strip_prefix(&prefix)
+ .ok_or(ErrorCategory::VersionConflict)?;
+ if !valid_version(version) {
+ return Err(ErrorCategory::VersionConflict);
+ }
+ Ok(version.to_owned())
+}
+fn checked_next(base: &str, next: &str) -> Result<(), ErrorCategory> {
+ let expected = Url::parse(&format!("{base}/versions")).map_err(|_| ErrorCategory::Source)?;
+ let url = Url::parse(next).map_err(|_| ErrorCategory::Source)?;
+ if url.scheme() != "https"
+ || url.host_str() != expected.host_str()
+ || url.port_or_known_default() != Some(443)
+ || url.path() != expected.path()
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || url.fragment().is_some()
+ {
+ return Err(ErrorCategory::Source);
+ }
+ Ok(())
+}
+
+impl SourceConnection for AzureSource {
+ fn get_version(&self, source: &SourceRef) -> Result {
+ let base = base_url(source)?;
+ let mut url = format!("{base}/versions?api-version={API_VERSION}&maxresults=25");
+ let mut latest: Option = None;
+ let mut ambiguous = false;
+ let mut seen = std::collections::BTreeSet::new();
+ for _ in 0..MAX_PAGES {
+ if !seen.insert(url.clone()) {
+ return Err(ErrorCategory::Source);
+ }
+ let bytes = self.transport.get(&url)?;
+ let page: VersionPage =
+ serde_json::from_slice(&bytes).map_err(|_| ErrorCategory::Source)?;
+ for item in page.value {
+ version_from_id(&base, &item.id)?;
+ match &latest {
+ Some(old) if item.attributes.created < old.attributes.created => {}
+ Some(old) if item.attributes.created == old.attributes.created => {
+ if item.id != old.id {
+ ambiguous = true;
+ }
+ }
+ _ => {
+ latest = Some(item);
+ ambiguous = false;
+ }
+ }
+ }
+ if let Some(next) = page.next_link {
+ checked_next(&base, &next)?;
+ url = next;
+ } else {
+ let latest = latest.ok_or(ErrorCategory::Source)?;
+ if ambiguous {
+ return Err(ErrorCategory::VersionConflict);
+ }
+ if !latest.attributes.usable(now()?) {
+ return Err(ErrorCategory::Source);
+ }
+ return version_from_id(&base, &latest.id).map(SourceVersion);
+ }
+ }
+ Err(ErrorCategory::Source)
+ }
+ fn get_value(
+ &self,
+ source: &SourceRef,
+ version: &SourceVersion,
+ ) -> Result {
+ let base = base_url(source)?;
+ if !valid_version(&version.0) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ let bytes = self
+ .transport
+ .get(&format!("{base}/{}?api-version={API_VERSION}", version.0))?;
+ let mut response: SecretResponse =
+ serde_json::from_slice(&bytes).map_err(|_| ErrorCategory::Source)?;
+ if version_from_id(&base, &response.id)? != version.0 {
+ return Err(ErrorCategory::VersionConflict);
+ }
+ if !response.attributes.usable(now()?) {
+ return Err(ErrorCategory::Source);
+ }
+ Ok(SecretPayload::new(
+ std::mem::take(&mut *response.value).into_bytes(),
+ SecretType::Text,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ collections::VecDeque,
+ sync::{Arc, Mutex},
+ };
+ const V1: &str = "11111111111111111111111111111111";
+ const V2: &str = "22222222222222222222222222222222";
+ const BASE: &str = "https://test-vault.vault.azure.net/secrets/test-secret";
+
+ // Explicitly opt-in: never runs in ordinary CI or reads existing secrets.
+ #[test]
+ #[ignore = "requires approved Azure synthetic-secret writes and Azure CLI sign-in"]
+ fn live_azure_rotation_and_cleanup() {
+ use crate::{
+ core::{
+ Binding, Config, Engine, MissingTargetPolicy, Schedule, SyncOutcome, TargetSpec,
+ },
+ targets::ProductionTarget,
+ };
+ assert_eq!(
+ std::env::var("TAR_VAULT_AZURE_LIVE_APPROVED").as_deref(),
+ Ok("yes")
+ );
+ let reference = SourceRef {
+ store: StoreKind::Azure,
+ connection: std::env::var("TAR_VAULT_AZURE_TEST_VAULT").expect("test vault required"),
+ entry: std::env::var("TAR_VAULT_AZURE_TEST_SECRET")
+ .expect("unique test secret required"),
+ };
+ assert!(reference.entry.starts_with("tarvaultsync-test-"));
+ let base = base_url(&reference).unwrap();
+ let token = cli_token().unwrap();
+ let token = std::str::from_utf8(&token).unwrap().trim();
+ let authorization = Zeroizing::new(format!("Bearer {token}"));
+ let agent = ureq::Agent::config_builder()
+ .timeout_global(Some(Duration::from_secs(15)))
+ .max_redirects(0)
+ .https_only(true)
+ .build()
+ .new_agent();
+ let missing = agent
+ .get(&format!("{base}/versions?api-version={API_VERSION}"))
+ .header("Authorization", authorization.as_str())
+ .call();
+ match missing {
+ Err(ureq::Error::StatusCode(404)) => {}
+ Ok(mut response) if response.status().as_u16() == 200 => {
+ let bytes = read_bounded(response.body_mut().as_reader(), MAX_RESPONSE).unwrap();
+ let page: VersionPage =
+ serde_json::from_slice(&bytes).expect("invalid metadata preflight response");
+ assert!(
+ page.value.is_empty() && page.next_link.is_none(),
+ "test name already exists"
+ );
+ }
+ Err(ureq::Error::StatusCode(code)) => {
+ panic!("metadata preflight denied: HTTP {code}; no secret was created")
+ }
+ _ => panic!("metadata preflight transport failed; no secret was created"),
+ }
+ struct Cleanup<'a> {
+ agent: &'a ureq::Agent,
+ authorization: &'a str,
+ url: String,
+ pending: bool,
+ }
+ impl Cleanup<'_> {
+ fn remove(&mut self) -> bool {
+ if !self.pending {
+ return true;
+ }
+ match self
+ .agent
+ .delete(&self.url)
+ .header("Authorization", self.authorization)
+ .call()
+ {
+ Ok(response) if response.status().is_success() => {
+ self.pending = false;
+ true
+ }
+ _ => false,
+ }
+ }
+ }
+ impl Drop for Cleanup<'_> {
+ fn drop(&mut self) {
+ if !self.remove() {
+ eprintln!("Synthetic Azure test cleanup failed; manual removal is required.");
+ }
+ }
+ }
+ let url = format!("{base}?api-version={API_VERSION}");
+ // Arm before PUT: a network timeout can follow a successful server write.
+ let mut cleanup = Cleanup {
+ agent: &agent,
+ authorization: authorization.as_str(),
+ url: url.clone(),
+ pending: true,
+ };
+ eprintln!("Synthetic Azure test secret: {}", reference.entry);
+ let put = |body: &'static [u8]| {
+ let mut response = agent
+ .put(&url)
+ .header("Authorization", authorization.as_str())
+ .header("Content-Type", "application/json")
+ .send(body)
+ .map_err(|_| ErrorCategory::Source)?;
+ let bytes = read_bounded(response.body_mut().as_reader(), MAX_RESPONSE)?;
+ let response: SecretResponse =
+ serde_json::from_slice(&bytes).map_err(|_| ErrorCategory::Source)?;
+ version_from_id(&base, &response.id)
+ };
+ let first =
+ put(br#"{"value":"synthetic-azure-integration-one","attributes":{"enabled":true}}"#)
+ .unwrap();
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("authorized-test-target.txt");
+ std::fs::write(&path, "initial").unwrap();
+ struct Counted {
+ calls: Arc,
+ }
+ impl Transport for Counted {
+ fn get(&self, url: &str) -> Result>, ErrorCategory> {
+ if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 20 {
+ return Err(ErrorCategory::Source);
+ }
+ HttpsTransport.get(url)
+ }
+ }
+ let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let source = AzureSource {
+ transport: Box::new(Counted {
+ calls: calls.clone(),
+ }),
+ };
+ let config = Config {
+ version: 1,
+ bindings: vec![Binding {
+ id: "live-azure".into(),
+ source: reference.clone(),
+ secret_type: SecretType::Text,
+ target: TargetSpec::WholeFile { path: path.clone() },
+ missing_target: MissingTargetPolicy::Alert,
+ schedule: Schedule {
+ interval_seconds: 60,
+ },
+ }],
+ };
+ let engine = Engine::new(
+ config,
+ dir.path().join("state.json"),
+ dir.path().join("events.ndjson"),
+ Arc::new(source),
+ Arc::new(ProductionTarget::new(dir.path().to_path_buf()).unwrap()),
+ )
+ .unwrap();
+ let runtime = tokio::runtime::Runtime::new().unwrap();
+ assert_eq!(
+ runtime.block_on(engine.sync("live-azure")),
+ SyncOutcome::Applied
+ );
+ assert_eq!(engine.status().applied["live-azure"].0, first);
+ let before = calls.load(std::sync::atomic::Ordering::SeqCst);
+ assert_eq!(
+ runtime.block_on(engine.sync("live-azure")),
+ SyncOutcome::Unchanged
+ );
+ assert_eq!(
+ calls.load(std::sync::atomic::Ordering::SeqCst) - before,
+ 1,
+ "unchanged version must only read metadata"
+ );
+ // Azure timestamps have second precision; avoid an intentionally ambiguous tie.
+ std::thread::sleep(Duration::from_secs(2));
+ let second =
+ put(br#"{"value":"synthetic-azure-integration-two","attributes":{"enabled":true}}"#)
+ .unwrap();
+ assert_ne!(first, second);
+ assert_eq!(
+ runtime.block_on(engine.sync("live-azure")),
+ SyncOutcome::Applied
+ );
+ assert_eq!(engine.status().applied["live-azure"].0, second);
+ let target = Zeroizing::new(std::fs::read(&path).unwrap());
+ assert!(target.as_slice() == b"synthetic-azure-integration-two");
+ for path in [&engine.state_path, &engine.events_path] {
+ assert!(!std::fs::read_to_string(path)
+ .unwrap()
+ .contains("synthetic-azure-integration-"));
+ }
+ assert!(cleanup.remove(), "synthetic secret deletion failed");
+ // Azure deletion is eventually visible to metadata listing. Do not
+ // mistake an immediately repeated old metadata snapshot for a failed delete.
+ let mut deletion_visible = false;
+ for _ in 0..10 {
+ match runtime.block_on(engine.sync("live-azure")) {
+ SyncOutcome::Failed(ErrorCategory::Source) => {
+ deletion_visible = true;
+ break;
+ }
+ SyncOutcome::Unchanged => std::thread::sleep(Duration::from_secs(2)),
+ _ => panic!("unexpected post-deletion sync result"),
+ }
+ }
+ assert!(
+ deletion_visible,
+ "deleted secret metadata did not converge within the bounded check"
+ );
+ assert_eq!(engine.status().applied["live-azure"].0, second);
+ assert!(Zeroizing::new(std::fs::read(&path).unwrap()).as_slice() == target.as_slice());
+ eprintln!("Azure rotation, unchanged-version, redaction, deletion and target-preservation checks passed; synthetic secret soft-deleted.");
+ eprintln!(
+ "Key Vault test requests: {}",
+ calls.load(std::sync::atomic::Ordering::SeqCst) + 4
+ );
+ }
+
+ struct Scripted {
+ replies: Mutex)>>,
+ calls: Arc>>,
+ }
+ impl Transport for Scripted {
+ fn get(&self, url: &str) -> Result>, ErrorCategory> {
+ self.calls.lock().unwrap().push(url.to_owned());
+ let (suffix, response) = self
+ .replies
+ .lock()
+ .unwrap()
+ .pop_front()
+ .expect("unexpected request");
+ assert!(url.contains(suffix));
+ response.map(|v| Zeroizing::new(v.into_bytes()))
+ }
+ }
+ fn reference() -> SourceRef {
+ SourceRef {
+ store: StoreKind::Azure,
+ connection: "test-vault".into(),
+ entry: "test-secret".into(),
+ }
+ }
+ fn page(version: &str, created: u64) -> String {
+ format!(
+ r#"{{"value":[{{"id":"{BASE}/{version}","attributes":{{"created":{created},"enabled":true}}}}]}}"#
+ )
+ }
+ fn value(version: &str) -> String {
+ format!(
+ r#"{{"id":"{BASE}/{version}","attributes":{{"created":10,"enabled":true}},"value":"synthetic-azure-probe"}}"#
+ )
+ }
+ fn scripted(
+ replies: Vec<(&'static str, Result)>,
+ ) -> (AzureSource, Arc>>) {
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ (
+ AzureSource {
+ transport: Box::new(Scripted {
+ replies: Mutex::new(replies.into()),
+ calls: calls.clone(),
+ }),
+ },
+ calls,
+ )
+ }
+
+ #[test]
+ fn endpoint_validation_rejects_injection_and_cross_host_pagination() {
+ for vault in [
+ "a",
+ "-vault",
+ "vault-",
+ "a--b",
+ "test.vault",
+ "x/../../",
+ "a&echo",
+ "a%PATH%",
+ ] {
+ let mut source = reference();
+ source.connection = vault.into();
+ assert!(!valid_source(&source));
+ }
+ for entry in ["", "../secret", "secret?query", "x#y", "a_b"] {
+ let mut source = reference();
+ source.entry = entry.into();
+ assert!(!valid_source(&source));
+ }
+ for next in [
+ "http://test-vault.vault.azure.net/secrets/test-secret/versions",
+ "https://attacker.invalid/secrets/test-secret/versions",
+ "https://test-vault.vault.azure.net/secrets/other/versions",
+ "https://user@test-vault.vault.azure.net/secrets/test-secret/versions",
+ "https://test-vault.vault.azure.net:444/secrets/test-secret/versions",
+ ] {
+ assert!(checked_next(BASE, next).is_err());
+ }
+ assert!(checked_next(
+ BASE,
+ &format!("{BASE}/versions?api-version={API_VERSION}&$skiptoken=opaque")
+ )
+ .is_ok());
+ assert!(read_bounded(&b"oversized"[..], 3).is_err());
+ }
+
+ #[test]
+ fn paginated_metadata_selects_latest_and_never_reads_value() {
+ let first = page(V1, 10).trim_end_matches('}').to_owned()
+ + &format!(
+ r#", "nextLink":"{BASE}/versions?api-version={API_VERSION}&$skiptoken=next"}}"#
+ );
+ let (source, calls) = scripted(vec![
+ ("/versions?", Ok(first)),
+ ("$skiptoken=next", Ok(page(V2, 20))),
+ ]);
+ assert_eq!(source.get_version(&reference()).unwrap().0, V2);
+ assert_eq!(calls.lock().unwrap().len(), 2);
+ assert!(calls
+ .lock()
+ .unwrap()
+ .iter()
+ .all(|url| url.contains("/versions?")));
+ }
+
+ #[test]
+ fn ambiguous_disabled_expired_and_untrusted_metadata_fail_closed() {
+ let tied = format!(
+ r#"{{"value":[{{"id":"{BASE}/{V1}","attributes":{{"created":10,"enabled":true}}}},{{"id":"{BASE}/{V2}","attributes":{{"created":10,"enabled":true}}}}]}}"#
+ );
+ for response in [
+ tied,
+ page(V1, 10).replace("true", "false"),
+ page(V1, 10).replace("\"enabled\":true", "\"enabled\":true,\"exp\":1"),
+ page(V1, 10).replace(
+ "\"enabled\":true",
+ "\"enabled\":true,\"nbf\":18446744073709551615",
+ ),
+ page(V1, 10).replace(BASE, "https://attacker.invalid/secrets/secret"),
+ "{\"value\":[]}".into(),
+ "not json".into(),
+ ] {
+ let (source, calls) = scripted(vec![("/versions?", Ok(response))]);
+ assert!(source.get_version(&reference()).is_err());
+ assert_eq!(calls.lock().unwrap().len(), 1);
+ }
+ let malicious_next = r#"{"value":[],"nextLink":"https://attacker.invalid/"}"#.to_owned();
+ let (source, calls) = scripted(vec![("/versions?", Ok(malicious_next))]);
+ assert!(source.get_version(&reference()).is_err());
+ assert_eq!(calls.lock().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn value_is_pinned_to_checked_version_and_failures_are_redacted() {
+ let (source, _) = scripted(vec![(V1, Ok(value(V2)))]);
+ assert!(matches!(
+ source.get_value(&reference(), &SourceVersion(V1.into())),
+ Err(ErrorCategory::VersionConflict)
+ ));
+ let (source, _) = scripted(vec![(V1, Ok("synthetic-azure-probe malformed".into()))]);
+ let error = source
+ .get_value(&reference(), &SourceVersion(V1.into()))
+ .err()
+ .unwrap();
+ assert_eq!(error.to_string(), "Source");
+ let (source, calls) = scripted(vec![]);
+ assert!(source
+ .get_value(&reference(), &SourceVersion("../latest".into()))
+ .is_err());
+ assert!(calls.lock().unwrap().is_empty());
+ }
+
+ #[tokio::test]
+ async fn engine_rotation_skips_unchanged_payload_and_preserves_target_on_failure() {
+ use crate::{
+ core::{
+ Binding, Config, Engine, MissingTargetPolicy, Schedule, SyncOutcome, TargetSpec,
+ },
+ targets::ProductionTarget,
+ };
+ let (source, calls) = scripted(vec![
+ ("/versions?", Ok(page(V1, 10))),
+ (V1, Ok(value(V1))),
+ ("/versions?", Ok(page(V1, 10))),
+ ("/versions?", Ok(page(V2, 20))),
+ (V2, Err(ErrorCategory::Unauthorized)),
+ ("/versions?", Ok(page(V2, 20))),
+ (V2, Ok(value(V2))),
+ ]);
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("authorized-target.txt");
+ std::fs::write(&path, "old").unwrap();
+ let config = Config {
+ version: 1,
+ bindings: vec![Binding {
+ id: "azure-test".into(),
+ source: reference(),
+ secret_type: SecretType::Text,
+ target: TargetSpec::WholeFile { path: path.clone() },
+ missing_target: MissingTargetPolicy::Alert,
+ schedule: Schedule {
+ interval_seconds: 60,
+ },
+ }],
+ };
+ let config_bytes = serde_json::to_vec(&config).unwrap();
+ crate::core::validate_config(&config_bytes, dir.path()).unwrap();
+ let engine = Engine::new(
+ config,
+ dir.path().join("state.json"),
+ dir.path().join("events.ndjson"),
+ Arc::new(source),
+ Arc::new(ProductionTarget::new(dir.path().to_path_buf()).unwrap()),
+ )
+ .unwrap();
+ assert_eq!(engine.sync("azure-test").await, SyncOutcome::Applied);
+ assert_eq!(engine.sync("azure-test").await, SyncOutcome::Unchanged);
+ assert_eq!(calls.lock().unwrap().len(), 3);
+ assert_eq!(
+ engine.sync("azure-test").await,
+ SyncOutcome::Failed(ErrorCategory::Unauthorized)
+ );
+ assert_eq!(engine.status().applied["azure-test"].0, V1);
+ let data = Zeroizing::new(std::fs::read(&path).unwrap());
+ assert!(data.as_slice() == b"synthetic-azure-probe");
+ assert_eq!(engine.sync("azure-test").await, SyncOutcome::Applied);
+ assert_eq!(engine.status().applied["azure-test"].0, V2);
+ for path in [&engine.state_path, &engine.events_path] {
+ assert!(!std::fs::read_to_string(path)
+ .unwrap()
+ .contains("synthetic-azure-probe"));
+ }
+ assert!(!String::from_utf8(config_bytes)
+ .unwrap()
+ .contains("synthetic-azure-probe"));
+ }
+}
diff --git a/src/browser_import.rs b/src/browser_import.rs
new file mode 100644
index 0000000..53911c0
--- /dev/null
+++ b/src/browser_import.rs
@@ -0,0 +1,266 @@
+//! Explicit, one-way import of a user-selected browser CSV into an encrypted vault.
+//! No browser profile access, background import, plaintext output, or payload logging.
+
+use crate::{
+ core::{safe_label, SecretPayload, SecretType},
+ vault::VaultSource,
+};
+use csv_core::{ReadRecordResult, Reader};
+use std::{collections::BTreeMap, fs::File, io::Read, path::Path};
+use zeroize::{Zeroize, Zeroizing};
+
+const MAX_BYTES: usize = 8 * 1024 * 1024;
+
+pub(crate) fn import_file(
+ source: &VaultSource,
+ path: &Path,
+ prefix: &str,
+ consent: bool,
+) -> Result {
+ if !consent {
+ return Err("Explicit import consent is required.");
+ }
+ if !source.is_unlocked() {
+ return Err("Unlock the vault before importing.");
+ }
+ if !safe_label(prefix) || prefix.len() > 48 {
+ return Err(
+ "Use a unique import prefix of 1–48 letters, numbers, dots, dashes or underscores.",
+ );
+ }
+ let file = File::open(path).map_err(|_| "Could not open the selected CSV.")?;
+ let metadata = file
+ .metadata()
+ .map_err(|_| "Could not read the selected CSV.")?;
+ if !metadata.is_file() || metadata.len() > MAX_BYTES as u64 {
+ return Err("Select a regular CSV file no larger than 8 MiB.");
+ }
+ let mut bytes = Zeroizing::new(Vec::new());
+ file.take(MAX_BYTES as u64 + 1)
+ .read_to_end(&mut bytes)
+ .map_err(|_| "Could not read the selected CSV.")?;
+ let entries = parse(&bytes, prefix)?;
+ source.import_entries(&entries).map_err(|_| "Import failed: the vault may be locked, storage unavailable, or the prefix already used. No entries were imported.")?;
+ Ok(entries.len())
+}
+
+fn parse(bytes: &[u8], prefix: &str) -> Result, &'static str> {
+ const INVALID: &str = "Invalid browser CSV. No entries were imported.";
+ if bytes.is_empty() || bytes.len() > MAX_BYTES || !safe_label(prefix) || prefix.len() > 48 {
+ return Err(INVALID);
+ }
+ let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes);
+ strict_quotes(bytes)?;
+ let mut input = bytes;
+ let mut parser = Reader::new();
+ let mut output = Zeroizing::new(vec![0; bytes.len() + 1]);
+ let mut ends = [0; 6];
+ let (mut written, mut fields) = (0, 0);
+ let mut columns: Option> = None;
+ let mut entries = Vec::new();
+ loop {
+ let (result, used, produced, count) =
+ parser.read_record(input, &mut output[written..], &mut ends[fields..]);
+ input = &input[used..];
+ written += produced;
+ fields += count;
+ match result {
+ ReadRecordResult::InputEmpty => continue,
+ ReadRecordResult::End => break,
+ ReadRecordResult::OutputFull | ReadRecordResult::OutputEndsFull => return Err(INVALID),
+ ReadRecordResult::Record => {
+ let mut start = 0;
+ let mut values = Vec::new();
+ for end in &ends[..fields] {
+ values.push(std::str::from_utf8(&output[start..*end]).map_err(|_| INVALID)?);
+ start = *end;
+ }
+ if let Some(columns) = &columns {
+ if fields != columns.len() || entries.len() >= 1000 {
+ return Err(INVALID);
+ }
+ let record: BTreeMap<_, _> = columns.iter().copied().zip(values).collect();
+ if record["url"].is_empty() || record["password"].is_empty() {
+ return Err(INVALID);
+ }
+ let mut json = Zeroizing::new(Vec::new());
+ serde_json::to_writer(&mut *json, &record).map_err(|_| INVALID)?;
+ entries.push((
+ format!("{prefix}-{:04}", entries.len() + 1),
+ SecretPayload::new(std::mem::take(&mut *json), SecretType::Json),
+ ));
+ } else {
+ let mut names = Vec::new();
+ for value in values {
+ let name = match value {
+ "name" => "name",
+ "url" => "url",
+ "username" => "username",
+ "password" => "password",
+ "note" | "notes" => "notes",
+ _ => return Err(INVALID),
+ };
+ if names.contains(&name) {
+ return Err(INVALID);
+ }
+ names.push(name);
+ }
+ if !["url", "username", "password"]
+ .iter()
+ .all(|name| names.contains(name))
+ {
+ return Err(INVALID);
+ }
+ columns = Some(names);
+ }
+ output[..written].zeroize();
+ written = 0;
+ fields = 0;
+ }
+ }
+ }
+ if entries.is_empty() {
+ return Err(INVALID);
+ }
+ Ok(entries)
+}
+
+// csv-core deliberately accepts malformed quoting. Reject it before decoding so
+// an incomplete export cannot silently change a password or discard a column.
+fn strict_quotes(bytes: &[u8]) -> Result<(), &'static str> {
+ enum State {
+ Start,
+ Plain,
+ Quoted,
+ Closed,
+ }
+ let mut state = State::Start;
+ for byte in bytes {
+ state = match (&state, byte) {
+ (State::Start, b'"') => State::Quoted,
+ (State::Start | State::Plain | State::Closed, b',' | b'\r' | b'\n') => State::Start,
+ (State::Plain, b'"') => return Err("Malformed CSV quoting. No entries were imported."),
+ (State::Quoted, b'"') => State::Closed,
+ (State::Closed, b'"') => State::Quoted,
+ (State::Closed, _) => return Err("Malformed CSV quoting. No entries were imported."),
+ (State::Quoted, _) => State::Quoted,
+ _ => State::Plain,
+ };
+ }
+ if matches!(state, State::Quoted) {
+ return Err("Incomplete CSV. No entries were imported.");
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn supported_exports_preserve_quoted_values_without_revealing_them() {
+ for input in [
+ "name,url,username,password\r\nsample,https://example.invalid,user,\"synthetic,\"\"quoted\"\"\"\r\n",
+ "\u{feff}name,url,username,password,notes\nsample,https://example.invalid,user,\"synthetic,\"\"quoted\"\"\",\"line1\nline2\"",
+ ] {
+ let entries = parse(input.as_bytes(), "import").unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].0, "import-0001");
+ let mut decoded: BTreeMap = serde_json::from_slice(entries[0].1.as_bytes()).unwrap();
+ assert!(decoded["password"] == "synthetic,\"quoted\"");
+ for value in decoded.values_mut() { value.zeroize(); }
+ }
+ }
+
+ #[test]
+ fn invalid_exports_fail_closed() {
+ for input in [
+ "url,username,password\n",
+ "url,username,password\na,b,",
+ "url,username,password\na,b,\"unfinished",
+ "url,username,password\na,b,\"closed\"garbage",
+ "url,username,password\na,b,un\"quoted",
+ "url,username,password,password\na,b,c,d",
+ "url,username,password\na,b,c,d",
+ "url,username,password,extra\na,b,c,d",
+ "url,password\na,b",
+ ] {
+ assert!(parse(input.as_bytes(), "import").is_err());
+ }
+ assert!(parse(b"url,username,password\na,b,c", "bad/prefix").is_err());
+ }
+
+ #[test]
+ fn explicit_consent_and_unlock_are_required_before_reading() {
+ let dir = tempfile::tempdir().unwrap();
+ let source = VaultSource::new(dir.path().join("vault.bin"), "local".into()).unwrap();
+ assert!(import_file(&source, Path::new("missing.csv"), "import", false).is_err());
+ assert!(import_file(&source, Path::new("missing.csv"), "import", true).is_err());
+ assert!(!source.exists());
+ }
+
+ #[test]
+ fn batch_is_encrypted_and_conflicts_do_not_change_the_vault() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("vault.bin");
+ let mut vault = crate::vault::LocalVault::create(&path, "test-only-passphrase").unwrap();
+ let entries = parse(
+ b"url,username,password\nhttps://example.invalid,user,synthetic-probe",
+ "import",
+ )
+ .unwrap();
+ vault.import_new(&entries).unwrap();
+ let before = std::fs::read(&path).unwrap();
+ assert!(!before.windows(15).any(|part| part == b"synthetic-probe"));
+ let mut conflict = parse(b"url,username,password\na,b,c", "fresh").unwrap();
+ conflict.extend(parse(b"url,username,password\na,b,c", "import").unwrap());
+ assert!(vault.import_new(&conflict).is_err());
+ assert_eq!(before, std::fs::read(&path).unwrap());
+ assert_eq!(vault.entries().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn file_import_is_all_or_nothing_and_leaves_source_untouched() {
+ let dir = tempfile::tempdir().unwrap();
+ let source = VaultSource::new(dir.path().join("vault.bin"), "local".into()).unwrap();
+ source.create("test-only-passphrase").unwrap();
+ let csv = dir.path().join("synthetic.csv");
+ let invalid = b"url,username,password\nhttps://example.invalid,user,synthetic\na,b,";
+ std::fs::write(&csv, invalid).unwrap();
+ let before = std::fs::read(dir.path().join("vault.bin")).unwrap();
+ assert!(import_file(&source, &csv, "browser", true).is_err());
+ assert_eq!(before, std::fs::read(dir.path().join("vault.bin")).unwrap());
+ let valid = b"url,username,password\nhttps://example.invalid,user,synthetic\n";
+ std::fs::write(&csv, valid).unwrap();
+ assert_eq!(import_file(&source, &csv, "browser", true).unwrap(), 1);
+ assert!(std::fs::read(&csv).unwrap() == valid);
+ assert!(import_file(&source, &csv, "browser", true).is_err());
+ assert_eq!(source.entries().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn oversized_input_and_record_count_are_rejected() {
+ assert!(parse(&vec![b'a'; MAX_BYTES + 1], "import").is_err());
+ let input = format!("url,username,password\n{}", "a,b,c\n".repeat(1001));
+ assert!(parse(input.as_bytes(), "import").is_err());
+ }
+
+ #[test]
+ fn encrypted_write_limit_rolls_back_entire_batch() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("vault.bin");
+ let mut vault = crate::vault::LocalVault::create(&path, "test-only-passphrase").unwrap();
+ let before = std::fs::read(&path).unwrap();
+ let entries: Vec<_> = (0..2)
+ .map(|i| {
+ (
+ format!("entry-{i}"),
+ SecretPayload::new(vec![0; MAX_BYTES / 2], SecretType::Binary),
+ )
+ })
+ .collect();
+ assert!(vault.import_new(&entries).is_err());
+ assert_eq!(before, std::fs::read(&path).unwrap());
+ assert!(vault.entries().unwrap().is_empty());
+ }
+}
diff --git a/src/cloud_auth.rs b/src/cloud_auth.rs
new file mode 100644
index 0000000..0b14ea8
--- /dev/null
+++ b/src/cloud_auth.rs
@@ -0,0 +1,442 @@
+//! Native OAuth: PKCE, a one-shot loopback callback, and OS credential storage.
+use crate::drive::{DriveError, Provider};
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
+use rand::{rngs::OsRng, RngCore};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::{
+ io::{Read, Write},
+ net::TcpListener,
+ time::{Duration, Instant},
+};
+use url::Url;
+use zeroize::{Zeroize, Zeroizing};
+
+const MICROSOFT_CLIENT: &str = "18435542-f783-44d8-9c63-c92e18bb46cb";
+const GOOGLE_CLIENT: &str =
+ "60346833173-iu18a5o5ki37208rijd998e0gltihrvj.apps.googleusercontent.com";
+
+#[derive(Serialize, Deserialize)]
+struct Credential {
+ provider: Provider,
+ refresh_token: String,
+ client_secret: String,
+}
+impl Drop for Credential {
+ fn drop(&mut self) {
+ self.refresh_token.zeroize();
+ self.client_secret.zeroize();
+ }
+}
+#[derive(Deserialize)]
+struct Tokens {
+ access_token: String,
+ refresh_token: Option,
+ token_type: String,
+}
+impl Drop for Tokens {
+ fn drop(&mut self) {
+ self.access_token.zeroize();
+ if let Some(token) = &mut self.refresh_token {
+ token.zeroize();
+ }
+ }
+}
+pub(crate) struct Login {
+ pub provider: Provider,
+ pub credential_id: String,
+ pub picked: Option,
+}
+
+pub(crate) fn random_id() -> String {
+ let mut bytes = [0u8; 32];
+ OsRng.fill_bytes(&mut bytes);
+ URL_SAFE_NO_PAD.encode(bytes)
+}
+fn endpoints(provider: Provider) -> (&'static str, &'static str, &'static str, &'static str) {
+ match provider {
+ Provider::OneDrive => (
+ MICROSOFT_CLIENT,
+ "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+ "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ "offline_access Files.ReadWrite",
+ ),
+ Provider::GoogleDrive => (
+ GOOGLE_CLIENT,
+ "https://accounts.google.com/o/oauth2/v2/auth",
+ "https://oauth2.googleapis.com/token",
+ "https://www.googleapis.com/auth/drive.file",
+ ),
+ }
+}
+pub(crate) fn agent() -> ureq::Agent {
+ ureq::Agent::config_builder()
+ .https_only(true)
+ .max_redirects(0)
+ .timeout_global(Some(Duration::from_secs(30)))
+ .build()
+ .new_agent()
+}
+fn credential_entry(id: &str) -> Result {
+ if !crate::drive::valid_id(id) {
+ return Err(DriveError::InvalidInput);
+ }
+ keyring::Entry::new("TAR Vault Sync OAuth", id).map_err(|_| DriveError::CredentialStore)
+}
+fn save(id: &str, credential: &Credential) -> Result<(), DriveError> {
+ let encoded =
+ Zeroizing::new(serde_json::to_string(credential).map_err(|_| DriveError::CredentialStore)?);
+ if encoded.len() > 64 * 1024 {
+ return Err(DriveError::CredentialStore);
+ }
+ let previous = head(id)?;
+ let next = CredentialHead {
+ generation: random_id(),
+ chunks: encoded.len().div_ceil(2000),
+ };
+ // Windows limits each credential blob to 2560 bytes. The small head is the
+ // commit point: a failed chunk write leaves the previous generation readable.
+ let write = (|| {
+ for (index, chunk) in encoded.as_bytes().chunks(2000).enumerate() {
+ credential_entry(&chunk_id(id, &next, index))?
+ .set_secret(chunk)
+ .map_err(|_| DriveError::CredentialStore)?;
+ }
+ credential_entry(id)?
+ .set_password(&serde_json::to_string(&next).map_err(|_| DriveError::CredentialStore)?)
+ .map_err(|_| DriveError::CredentialStore)
+ })();
+ if write.is_err() {
+ remove_chunks(id, &next)?;
+ return write;
+ }
+ if let Some(previous) = previous {
+ remove_chunks(id, &previous)?;
+ }
+ Ok(())
+}
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CredentialHead {
+ generation: String,
+ chunks: usize,
+}
+fn head(id: &str) -> Result, DriveError> {
+ let value = match credential_entry(id)?.get_password() {
+ Ok(value) => Zeroizing::new(value),
+ Err(keyring::Error::NoEntry) => return Ok(None),
+ Err(_) => return Err(DriveError::CredentialStore),
+ };
+ let head: CredentialHead =
+ serde_json::from_str(&value).map_err(|_| DriveError::CredentialStore)?;
+ if !crate::drive::valid_id(&head.generation) || !(1..=33).contains(&head.chunks) {
+ return Err(DriveError::CredentialStore);
+ }
+ Ok(Some(head))
+}
+fn chunk_id(id: &str, head: &CredentialHead, index: usize) -> String {
+ format!("{id}-{}-{index}", head.generation)
+}
+fn remove_entry(id: &str) -> Result<(), DriveError> {
+ match credential_entry(id)?.delete_credential() {
+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
+ Err(_) => Err(DriveError::CredentialStore),
+ }
+}
+fn remove_chunks(id: &str, head: &CredentialHead) -> Result<(), DriveError> {
+ for index in 0..head.chunks {
+ remove_entry(&chunk_id(id, head, index))?;
+ }
+ Ok(())
+}
+fn read_credential(id: &str) -> Result>, DriveError> {
+ let head = head(id)?.ok_or(DriveError::Authorization)?;
+ let mut bytes = Zeroizing::new(Vec::new());
+ for index in 0..head.chunks {
+ let chunk = Zeroizing::new(
+ credential_entry(&chunk_id(id, &head, index))?
+ .get_secret()
+ .map_err(|_| DriveError::CredentialStore)?,
+ );
+ if chunk.len() > 2000 {
+ return Err(DriveError::CredentialStore);
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ Ok(bytes)
+}
+pub(crate) fn disconnect(id: &str) -> Result<(), DriveError> {
+ if let Some(head) = head(id)? {
+ remove_chunks(id, &head)?;
+ }
+ remove_entry(id)
+}
+fn exchange(provider: Provider, fields: &[(&str, &str)]) -> Result {
+ let body = Zeroizing::new(
+ url::form_urlencoded::Serializer::new(String::new())
+ .extend_pairs(fields.iter().copied())
+ .finish(),
+ );
+ let mut response = agent()
+ .post(endpoints(provider).2)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .send(body.as_bytes())
+ .map_err(|_| DriveError::Authorization)?;
+ let bytes = crate::drive::bounded(response.body_mut().as_reader(), 64 * 1024)?;
+ let tokens: Tokens = serde_json::from_slice(&bytes).map_err(|_| DriveError::Authorization)?;
+ if !tokens.token_type.eq_ignore_ascii_case("bearer") || tokens.access_token.is_empty() {
+ return Err(DriveError::Authorization);
+ }
+ Ok(tokens)
+}
+pub(crate) fn access_token(provider: Provider, id: &str) -> Result, DriveError> {
+ // Serialize refresh-token rotation across different connections/process-local clients.
+ static REFRESH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+ let _guard = REFRESH_LOCK.lock().map_err(|_| DriveError::Authorization)?;
+ let bytes = read_credential(id)?;
+ let mut credential: Credential =
+ serde_json::from_slice(&bytes).map_err(|_| DriveError::CredentialStore)?;
+ if credential.provider != provider {
+ return Err(DriveError::Authorization);
+ }
+ let mut fields = vec![
+ ("client_id", endpoints(provider).0),
+ ("grant_type", "refresh_token"),
+ ("refresh_token", credential.refresh_token.as_str()),
+ ];
+ if provider == Provider::GoogleDrive {
+ fields.push(("client_secret", &credential.client_secret));
+ }
+ let mut tokens = exchange(provider, &fields)?;
+ if let Some(refresh) = tokens.refresh_token.take() {
+ credential.refresh_token.zeroize();
+ credential.refresh_token = refresh;
+ save(id, &credential)?;
+ }
+ Ok(Zeroizing::new(std::mem::take(&mut tokens.access_token)))
+}
+
+fn callback(target: &str, state: &str) -> Result<(Zeroizing, Option), DriveError> {
+ if !target.starts_with("/?") {
+ return Err(DriveError::Authorization);
+ }
+ let url =
+ Url::parse(&format!("http://127.0.0.1{target}")).map_err(|_| DriveError::Authorization)?;
+ let pairs: Vec<_> = url.query_pairs().collect();
+ let single = |key: &str| -> Result, DriveError> {
+ let values: Vec<_> = pairs.iter().filter(|(k, _)| k == key).collect();
+ if values.len() > 1 {
+ return Err(DriveError::Authorization);
+ }
+ Ok(values.first().map(|(_, value)| value.to_string()))
+ };
+ if single("state")?.as_deref() != Some(state) || single("error")?.is_some() {
+ return Err(DriveError::Authorization);
+ }
+ let code = Zeroizing::new(
+ single("code")?
+ .filter(|s| !s.is_empty())
+ .ok_or(DriveError::Authorization)?,
+ );
+ let picked = single("picked_file_ids")?;
+ if picked.as_ref().is_some_and(|s| !crate::drive::valid_id(s)) {
+ return Err(DriveError::InvalidInput);
+ }
+ Ok((code, picked))
+}
+
+pub(crate) fn login(
+ provider: Provider,
+ client_secret: Zeroizing,
+) -> Result {
+ if provider == Provider::GoogleDrive && client_secret.is_empty() {
+ return Err(DriveError::InvalidInput);
+ }
+ let listener = TcpListener::bind("127.0.0.1:0").map_err(|_| DriveError::Authorization)?;
+ listener
+ .set_nonblocking(true)
+ .map_err(|_| DriveError::Authorization)?;
+ let port = listener
+ .local_addr()
+ .map_err(|_| DriveError::Authorization)?
+ .port();
+ let host = if provider == Provider::GoogleDrive {
+ "127.0.0.1"
+ } else {
+ "localhost"
+ };
+ let redirect = format!("http://{host}:{port}/");
+ let state = random_id();
+ let verifier = Zeroizing::new(random_id());
+ let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
+ let (client, endpoint, _, scope) = endpoints(provider);
+ let mut url = Url::parse(endpoint).map_err(|_| DriveError::Authorization)?;
+ url.query_pairs_mut().extend_pairs([
+ ("client_id", client),
+ ("redirect_uri", redirect.as_str()),
+ ("response_type", "code"),
+ ("scope", scope),
+ ("state", state.as_str()),
+ ("code_challenge", challenge.as_str()),
+ ("code_challenge_method", "S256"),
+ ("prompt", "consent"),
+ ]);
+ if provider == Provider::GoogleDrive {
+ url.query_pairs_mut().extend_pairs([
+ ("access_type", "offline"),
+ ("trigger_onepick", "true"),
+ ("allow_folder_selection", "true"),
+ ("allow_multiple", "false"),
+ ]);
+ }
+ webbrowser::open(url.as_str()).map_err(|_| DriveError::Authorization)?;
+ let deadline = Instant::now() + Duration::from_secs(180);
+ let (code, picked) = loop {
+ if Instant::now() >= deadline {
+ return Err(DriveError::Authorization);
+ }
+ match listener.accept() {
+ Ok((mut stream, peer)) => {
+ if !peer.ip().is_loopback() {
+ continue;
+ }
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .map_err(|_| DriveError::Authorization)?;
+ stream
+ .set_write_timeout(Some(Duration::from_secs(2)))
+ .map_err(|_| DriveError::Authorization)?;
+ let mut request = Zeroizing::new(Vec::new());
+ // Read only the bounded request line, never log its authorization code.
+ for _ in 0..16_384 {
+ let mut byte = [0];
+ if stream
+ .read(&mut byte)
+ .map_err(|_| DriveError::Authorization)?
+ == 0
+ {
+ break;
+ }
+ request.push(byte[0]);
+ if byte[0] == b'\n' {
+ break;
+ }
+ }
+ let line = std::str::from_utf8(&request).map_err(|_| DriveError::Authorization)?;
+ let mut words = line.split_whitespace();
+ let result = if words.next() == Some("GET") {
+ callback(words.next().unwrap_or_default(), &state)
+ } else {
+ Err(DriveError::Authorization)
+ };
+ let response = if result.is_ok() {
+ "HTTP/1.1 200 OK\r\n"
+ } else {
+ "HTTP/1.1 400 Bad Request\r\n"
+ };
+ stream.write_all(format!("{response}Content-Type: text/plain\r\nCache-Control: no-store\r\nContent-Security-Policy: default-src 'none'\r\nConnection: close\r\n\r\nReturn to TAR Vault Sync. No credentials are displayed here.").as_bytes()).map_err(|_| DriveError::Authorization)?;
+ if let Ok(value) = result {
+ break value;
+ }
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(100))
+ }
+ Err(_) => return Err(DriveError::Authorization),
+ }
+ };
+ let mut fields = vec![
+ ("client_id", client),
+ ("grant_type", "authorization_code"),
+ ("code", code.as_str()),
+ ("redirect_uri", redirect.as_str()),
+ ("code_verifier", verifier.as_str()),
+ ];
+ if provider == Provider::GoogleDrive {
+ fields.push(("client_secret", client_secret.as_str()));
+ }
+ let mut tokens = exchange(provider, &fields)?;
+ let credential_id = random_id();
+ let credential = Credential {
+ provider,
+ refresh_token: tokens
+ .refresh_token
+ .take()
+ .ok_or(DriveError::Authorization)?,
+ client_secret: client_secret.to_string(),
+ };
+ save(&credential_id, &credential)?;
+ Ok(Login {
+ provider,
+ credential_id,
+ picked,
+ })
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+ #[test]
+ #[ignore = "uses the native OS credential store with an isolated synthetic credential"]
+ fn native_credential_store_roundtrip() {
+ let id = random_id();
+ let credential = Credential {
+ provider: Provider::OneDrive,
+ refresh_token: "synthetic-test-token-not-valid".repeat(400),
+ client_secret: String::new(),
+ };
+ save(&id, &credential).unwrap();
+ let read = read_credential(&id);
+ let removed = disconnect(&id);
+ let read = read.unwrap();
+ let decoded: Credential = serde_json::from_slice(&read).unwrap();
+ assert!(decoded.refresh_token == credential.refresh_token);
+ removed.unwrap();
+ assert!(matches!(
+ credential_entry(&id).unwrap().get_password(),
+ Err(keyring::Error::NoEntry)
+ ));
+ }
+ #[test]
+ fn callback_rejects_csrf_duplicates_and_untrusted_picker_ids() {
+ assert!(callback("/?state=s&code=x", "s").is_ok());
+ for target in [
+ "/?state=wrong&code=x",
+ "/?state=s&state=s&code=x",
+ "/?state=s&code=x&code=y",
+ "/?state=s&error=denied",
+ "/other?state=s&code=x",
+ "/?state=s&code=x&picked_file_ids=..%2Fescape",
+ ] {
+ assert!(callback(target, "s").is_err());
+ }
+ }
+ pub(crate) fn test_google_client_secret() -> Result, DriveError> {
+ #[derive(Deserialize)]
+ struct Installed {
+ client_id: String,
+ client_secret: String,
+ }
+ impl Drop for Installed {
+ fn drop(&mut self) {
+ self.client_secret.zeroize();
+ }
+ }
+ #[derive(Deserialize)]
+ struct ClientFile {
+ installed: Installed,
+ }
+ let path =
+ std::env::var_os("TAR_VAULT_GOOGLE_CLIENT_FILE").ok_or(DriveError::InvalidInput)?;
+ let file = std::fs::File::open(path).map_err(|_| DriveError::InvalidInput)?;
+ let bytes = crate::drive::bounded(file, 64 * 1024)?;
+ let mut client: ClientFile =
+ serde_json::from_slice(&bytes).map_err(|_| DriveError::InvalidInput)?;
+ if client.installed.client_id != GOOGLE_CLIENT {
+ return Err(DriveError::InvalidInput);
+ }
+ Ok(Zeroizing::new(std::mem::take(
+ &mut client.installed.client_secret,
+ )))
+ }
+}
diff --git a/src/core.rs b/src/core.rs
index 34e22ef..eea543f 100644
--- a/src/core.rs
+++ b/src/core.rs
@@ -366,7 +366,19 @@ pub fn validate_config(data: &[u8], root: &Path) -> Result return Err(ErrorCategory::InvalidConfig),
}
if matches!(b.source.store, StoreKind::Fake) != matches!(b.target, TargetSpec::Fake { .. })
- || !matches!(b.source.store, StoreKind::Fake | StoreKind::LocalVault)
+ || !matches!(
+ b.source.store,
+ StoreKind::Fake
+ | StoreKind::LocalVault
+ | StoreKind::Azure
+ | StoreKind::OneDrive
+ | StoreKind::GoogleDrive
+ )
+ {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ if b.source.store == StoreKind::Azure
+ && (!crate::azure::valid_source(&b.source) || b.secret_type != SecretType::Text)
{
return Err(ErrorCategory::InvalidConfig);
}
@@ -459,6 +471,9 @@ fn atomic_json(path: &Path, value: &T) -> Result<(), ErrorCategory
f.write_all(&bytes)
.and_then(|_| f.sync_all())
.map_err(|_| ErrorCategory::State)?;
+ // ReplaceFileW needs to reopen the replacement with read/delete access.
+ // File::create leaves a write-only handle open until explicitly dropped.
+ drop(f);
replace_file(&tmp, path).map_err(|_| ErrorCategory::State)
}
@@ -725,7 +740,12 @@ impl Engine {
MissingTargetPolicy::Recreate => {}
}
}
- let version = match self.source.get_version(&b.source) {
+ let source = self.source.clone();
+ let reference = b.source.clone();
+ let version = match tokio::task::spawn_blocking(move || source.get_version(&reference))
+ .await
+ .unwrap_or(Err(ErrorCategory::Source))
+ {
Ok(v) => v,
Err(e) => return SyncOutcome::Failed(e),
};
@@ -748,7 +768,15 @@ impl Engine {
SyncOutcome::Unchanged
};
}
- let payload = match self.source.get_value(&b.source, &version) {
+ let source = self.source.clone();
+ let reference = b.source.clone();
+ let requested_version = version.clone();
+ let payload = match tokio::task::spawn_blocking(move || {
+ source.get_value(&reference, &requested_version)
+ })
+ .await
+ .unwrap_or(Err(ErrorCategory::Source))
+ {
Ok(p) => p,
Err(e) => return SyncOutcome::Failed(e),
};
@@ -870,6 +898,39 @@ impl LocalTarget for FakeTarget {
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn state_replacement_persists_repeated_updates() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("state.json");
+ let mut state = State {
+ version: SCHEMA_VERSION,
+ ..State::default()
+ };
+ atomic_json(&path, &state).unwrap();
+ for version in ["v1", "v2", "v3"] {
+ state
+ .applied
+ .insert("binding".into(), SourceVersion(version.into()));
+ atomic_json(&path, &state).unwrap();
+ assert_eq!(load_state(&path).unwrap().applied, state.applied);
+ assert!(!path.with_extension("tmp").exists());
+ }
+ }
+
+ #[test]
+ fn failed_replacement_preserves_previous_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("state.json");
+ let state = State {
+ version: SCHEMA_VERSION,
+ ..State::default()
+ };
+ atomic_json(&path, &state).unwrap();
+ let previous = fs::read(&path).unwrap();
+ assert!(replace_file(&dir.path().join("missing.tmp"), &path).is_err());
+ assert_eq!(fs::read(&path).unwrap(), previous);
+ }
+
#[test]
fn single_agent_lock_rejects_second_writer() {
let dir = tempfile::tempdir().unwrap();
diff --git a/src/desktop.rs b/src/desktop.rs
index ca5ce02..ed52ded 100644
--- a/src/desktop.rs
+++ b/src/desktop.rs
@@ -6,6 +6,7 @@ use crate::{
SecretPayload, SecretType, SourceRef, StoreKind, StructuredFormat, SyncOutcome, TargetSpec,
},
scheduler,
+ sources::{Connection, Location, Sources},
targets::ProductionTarget,
vault::VaultSource,
};
@@ -20,12 +21,19 @@ use std::{
use tokio::task::JoinHandle;
use zeroize::{Zeroize, Zeroizing};
-const ACCENT: Color32 = Color32::from_rgb(204, 239, 123);
-const MUTED: Color32 = Color32::from_rgb(145, 166, 180);
+const ACCENT: Color32 = Color32::from_rgb(15, 118, 110);
+const MUTED: Color32 = Color32::from_rgb(105, 119, 137);
+const INK: Color32 = Color32::from_rgb(24, 39, 57);
+const CANVAS: Color32 = Color32::from_rgb(245, 247, 250);
+const BORDER: Color32 = Color32::from_rgb(225, 231, 237);
+const NAV: Color32 = Color32::from_rgb(19, 32, 48);
+const DANGER: Color32 = Color32::from_rgb(185, 55, 62);
+const BRAND_MARK: &[u8] = include_bytes!("../assets/branding/tar-vault-sync-mark-v1.png");
#[derive(Clone, Copy, PartialEq, Eq)]
enum Page {
Overview,
+ Sources,
Vault,
Bindings,
Events,
@@ -56,11 +64,11 @@ impl TargetChoice {
];
fn label(self) -> &'static str {
match self {
- Self::WholeFile => "Tüm dosya",
- Self::DotEnv => ".env alanı",
- Self::Json => "JSON alanı",
- Self::Yaml => "YAML alanı",
- Self::Properties => "Properties alanı",
+ Self::WholeFile => "Whole file",
+ Self::DotEnv => ".env variable",
+ Self::Json => "JSON field",
+ Self::Yaml => "YAML field",
+ Self::Properties => "Properties field",
Self::DockerCompose => "Docker Compose environment",
Self::DockerEnvFile => "Docker env_file",
Self::GitCredential => "Git Credential Manager",
@@ -70,6 +78,8 @@ impl TargetChoice {
struct BindingForm {
original_id: Option,
+ source_kind: StoreKind,
+ connection: String,
id: String,
entry: String,
secret_type: SecretType,
@@ -88,6 +98,8 @@ impl Default for BindingForm {
fn default() -> Self {
Self {
original_id: None,
+ source_kind: StoreKind::LocalVault,
+ connection: "local".into(),
id: String::new(),
entry: String::new(),
secret_type: SecretType::Text,
@@ -108,6 +120,8 @@ impl BindingForm {
fn from_binding(binding: &Binding) -> Self {
let mut form = Self {
original_id: Some(binding.id.clone()),
+ source_kind: binding.source.store.clone(),
+ connection: binding.source.connection.clone(),
id: binding.id.clone(),
entry: binding.source.entry.clone(),
secret_type: binding.secret_type.clone(),
@@ -206,12 +220,12 @@ impl BindingForm {
let interval_seconds = self
.interval
.parse()
- .map_err(|_| "Kontrol aralığı geçersiz")?;
+ .map_err(|_| "Enter a valid check interval.")?;
Ok(Binding {
id: self.id.clone(),
source: SourceRef {
- store: StoreKind::LocalVault,
- connection: "local".into(),
+ store: self.source_kind.clone(),
+ connection: self.connection.clone(),
entry: self.entry.clone(),
},
secret_type: self.secret_type.clone(),
@@ -223,8 +237,17 @@ impl BindingForm {
}
struct DesktopApp {
+ brand_texture: Option,
+ pending_sync: Option>,
+ pending_vault: Option>>,
root: PathBuf,
source: Arc,
+ sources: Arc,
+ selected_source: String,
+ connection_id: String,
+ connection_path: String,
+ connection_azure: bool,
+ drive_form: crate::drive_ui::DriveForm,
target: Arc,
engine: Arc,
runtime: tokio::runtime::Runtime,
@@ -239,6 +262,9 @@ struct DesktopApp {
secret_text: Zeroizing,
secret_file: String,
backup_path: String,
+ import_path: String,
+ import_prefix: String,
+ import_consent: bool,
root_input: String,
form: BindingForm,
editing: bool,
@@ -264,23 +290,20 @@ impl DesktopApp {
bindings: Vec::new(),
}
};
- if config
- .bindings
- .iter()
- .any(|b| b.source.store != StoreKind::LocalVault || b.source.connection != "local")
- {
- return Err("Masaüstü arayüzü yalnızca yerel kasa eşlemelerini yönetir".into());
- }
let source = Arc::new(VaultSource::new(
root.join("local/vault.bin"),
"local".into(),
)?);
let target = Arc::new(ProductionTarget::new(root.clone())?);
+ let sources = Arc::new(Sources::open(&root, source.clone())?);
+ for binding in &config.bindings {
+ sources.validate_ref(&binding.source)?;
+ }
let engine = Arc::new(Engine::new(
config,
root.join("local/state.json"),
root.join("local/events.ndjson"),
- source.clone(),
+ sources.clone(),
target.clone(),
)?);
let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -288,9 +311,18 @@ impl DesktopApp {
.build()?;
let schedules = spawn_schedules(&runtime, &engine);
Ok(Self {
+ brand_texture: None,
+ pending_sync: None,
+ pending_vault: None,
root_input: root.display().to_string(),
root,
source,
+ sources,
+ selected_source: "local".into(),
+ connection_id: String::new(),
+ connection_path: String::new(),
+ connection_azure: false,
+ drive_form: crate::drive_ui::DriveForm::default(),
target,
engine,
runtime,
@@ -305,6 +337,9 @@ impl DesktopApp {
secret_text: Zeroizing::new(String::new()),
secret_file: String::new(),
backup_path: String::new(),
+ import_path: String::new(),
+ import_prefix: String::new(),
+ import_consent: false,
form: BindingForm::default(),
editing: false,
pending_delete_entry: None,
@@ -320,27 +355,32 @@ impl DesktopApp {
self.is_error = true;
}
fn save_config(&mut self, config: Config) -> Result<(), &'static str> {
- let bytes = serde_json::to_vec_pretty(&config).map_err(|_| "Yapılandırma kodlanamadı")?;
- core::validate_config(&bytes, &self.root).map_err(|_| "Yapılandırma geçersiz")?;
+ if self.pending_sync.is_some() {
+ return Err("Wait for the current sync to finish before changing bindings.");
+ }
+ let bytes = serde_json::to_vec_pretty(&config)
+ .map_err(|_| "Could not encode the configuration.")?;
+ core::validate_config(&bytes, &self.root).map_err(|_| "The configuration is invalid.")?;
for binding in &config.bindings {
- if binding.source.store != StoreKind::LocalVault
- || binding.source.connection != "local"
+ if self.sources.validate_ref(&binding.source).is_err()
|| self
.target
.validate(&binding.target, &binding.secret_type)
.is_err()
{
- return Err("Hedef veya sır türü geçersiz");
+ return Err("The target or secret type is invalid.");
}
}
let path = self.root.join("shared/config.json");
let on_disk = match fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
- Err(_) => return Err("Yapılandırma okunamadı"),
+ Err(_) => return Err("Could not read the configuration."),
};
if on_disk != self.config_bytes {
- return Err("Yapılandırma başka bir süreçte değişti; pencereyi yeniden açın");
+ return Err(
+ "Configuration changed in another process. Reopen this window before saving.",
+ );
}
let changed: Vec = self
.engine
@@ -362,24 +402,24 @@ impl DesktopApp {
}
if self.engine.invalidate_bindings(&changed).is_err() {
self.schedules = spawn_schedules(&self.runtime, &self.engine);
- return Err("Eşleme durumu güncellenemedi");
+ return Err("Could not update binding status.");
}
let next = match Engine::new(
config,
self.root.join("local/state.json"),
self.root.join("local/events.ndjson"),
- self.source.clone(),
+ self.engine.source.clone(),
self.target.clone(),
) {
Ok(engine) => Arc::new(engine),
Err(_) => {
self.schedules = spawn_schedules(&self.runtime, &self.engine);
- return Err("Durum dosyası okunamadı");
+ return Err("Could not read the state file.");
}
};
if write_config(&path, &bytes).is_err() {
self.schedules = spawn_schedules(&self.runtime, &self.engine);
- return Err("Yapılandırma yazılamadı");
+ return Err("Could not save the configuration.");
}
self.config_bytes = Some(bytes);
self.schedules = spawn_schedules(&self.runtime, &next);
@@ -397,7 +437,7 @@ impl DesktopApp {
let mut config = self.engine.config.clone();
if let Some(old_id) = &self.form.original_id {
let Some(existing) = config.bindings.iter_mut().find(|b| &b.id == old_id) else {
- self.error("Eşleme bulunamadı");
+ self.error("Binding not found.");
return;
};
*existing = binding;
@@ -408,7 +448,7 @@ impl DesktopApp {
Ok(()) => {
self.editing = false;
self.form = BindingForm::default();
- self.notice("Eşleme kaydedildi ve zamanlayıcı güncellendi");
+ self.notice("Binding saved. The sync schedule is up to date.");
}
Err(e) => self.error(e),
}
@@ -417,34 +457,52 @@ impl DesktopApp {
let mut config = self.engine.config.clone();
config.bindings.retain(|b| b.id != id);
match self.save_config(config) {
- Ok(()) => self.notice("Eşleme kaldırıldı"),
+ Ok(()) => self.notice("Binding removed."),
Err(e) => self.error(e),
}
}
fn sync_binding(&mut self, id: &str) {
- let outcome = self.runtime.block_on(self.engine.sync(id));
- match outcome {
- SyncOutcome::Failed(_) => {
- self.error("Senkronizasyon başarısız; durum ve olayları kontrol edin")
- }
- _ => self.notice("Senkronizasyon kontrolü tamamlandı"),
+ if self.pending_sync.is_some() {
+ self.notice("A sync check is already running.");
+ return;
}
+ let engine = self.engine.clone();
+ let id = id.to_owned();
+ self.pending_sync = Some(self.runtime.spawn(async move { engine.sync(&id).await }));
+ self.notice("Sync check running…");
}
fn vault_action(&mut self, action: &'static str) {
- let passphrase = self.passphrase.as_str();
- let result = match action {
- "create" => self.source.create(passphrase),
- "unlock" => self.source.unlock(passphrase),
- "recover" => self
- .source
- .recover(&PathBuf::from(&self.backup_path), passphrase),
- _ => return,
- };
- self.passphrase.zeroize();
- match result {
- Ok(()) => self.notice("Kasa işlemi tamamlandı"),
- Err(_) => {
- self.error("Kasa işlemi başarısız; parola, yol ve kasa durumunu kontrol edin")
+ let passphrase = Zeroizing::new(std::mem::take(&mut *self.passphrase));
+ let backup = PathBuf::from(&self.backup_path);
+ self.run_vault(move |source| {
+ match action {
+ "create" => source.create(&passphrase),
+ "unlock" => source.unlock(&passphrase),
+ "recover" => source.recover(&backup, &passphrase),
+ _ => return Err("Unknown vault operation.".into()),
+ }
+ .map(|_| "Vault operation complete.".into())
+ .map_err(|error| {
+ format!("Vault operation failed: {error}. Check account access and passphrase.")
+ })
+ });
+ }
+ fn run_vault(
+ &mut self,
+ action: impl FnOnce(Arc) -> Result + Send + 'static,
+ ) {
+ if self.pending_vault.is_some() {
+ self.notice("A vault operation is already running.");
+ return;
+ }
+ let source = self.source.clone();
+ if source.is_remote() {
+ self.pending_vault = Some(self.runtime.spawn_blocking(move || action(source)));
+ self.notice("Cloud vault operation running…");
+ } else {
+ match action(source) {
+ Ok(message) => self.notice(&message),
+ Err(message) => self.error(&message),
}
}
}
@@ -455,213 +513,466 @@ impl DesktopApp {
match fs::read(&self.secret_file) {
Ok(bytes) => bytes,
Err(_) => {
- self.error("Sır dosyası okunamadı");
+ self.error("Could not read the secret file.");
return;
}
}
};
let payload = SecretPayload::new(bytes, self.entry_kind.clone());
- let result = self.source.put_entry(&self.entry_id, &payload);
+ let id = self.entry_id.clone();
self.secret_text.zeroize();
self.secret_file.clear();
- match result {
- Ok(()) => self.notice("Kasa kaydı yeni sürümle kaydedildi"),
- Err(_) => self.error("Kasa kaydı kaydedilemedi"),
+ self.run_vault(move |source| source.put_entry(&id, &payload)
+ .map(|_| "Secret saved as a new version.".into())
+ .map_err(|error| format!("Could not save the secret: {error}. On conflict, unlock again to reload before retrying.")));
+ }
+ fn import_browser_csv(&mut self) {
+ let consent = std::mem::take(&mut self.import_consent);
+ if self.source.is_remote() {
+ let path = PathBuf::from(&self.import_path);
+ let prefix = self.import_prefix.clone();
+ self.run_vault(move |source| crate::browser_import::import_file(&source, &path, &prefix, consent)
+ .map(|count| format!("Imported {count} encrypted records. The original plaintext CSV was not deleted."))
+ .map_err(str::to_owned));
+ return;
+ }
+ match crate::browser_import::import_file(
+ &self.source,
+ &PathBuf::from(&self.import_path),
+ &self.import_prefix,
+ consent,
+ ) {
+ Ok(count) => {
+ self.import_path.clear();
+ self.import_prefix.clear();
+ self.notice(&format!("Imported {count} encrypted records. The original plaintext CSV was not deleted; remove it when no longer needed."));
+ }
+ Err(message) => self.error(message),
}
}
fn header(&mut self, ui: &mut egui::Ui) {
+ let (title, subtitle) = match self.page {
+ Page::Overview => ("Overview", "A clear view of your local secret workspace."),
+ Page::Sources => (
+ "Sources",
+ "Manage the vaults that supply your local targets.",
+ ),
+ Page::Vault => ("Vault", "Secure storage. Simple access. Always local."),
+ Page::Bindings => (
+ "Bindings",
+ "Connect your secrets to the places they are needed.",
+ ),
+ Page::Events => (
+ "Activity",
+ "Follow sync results and changes across your workspace.",
+ ),
+ Page::Settings => ("Settings", "Manage your workspace and desktop agent."),
+ };
+ ui.label(
+ RichText::new("WORKSPACE / TAR VAULT SYNC")
+ .size(10.0)
+ .strong()
+ .color(MUTED),
+ );
+ ui.add_space(10.0);
ui.horizontal(|ui| {
- ui.heading(match self.page {
- Page::Overview => "Genel Bakış",
- Page::Vault => "Şifreli Kasa",
- Page::Bindings => "Eşlemeler",
- Page::Events => "Olaylar",
- Page::Settings => "Yönetim",
- });
+ ui.heading(RichText::new(title).size(30.0).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- let label = if self.source.is_unlocked() {
- "● Kasa açık"
- } else {
- "○ Kasa kilitli"
- };
- ui.label(RichText::new(label).color(if self.source.is_unlocked() {
- ACCENT
- } else {
- MUTED
- }));
+ let unlocked = self.source.is_unlocked();
+ egui::Frame::group(ui.style())
+ .fill(if unlocked {
+ Color32::from_rgb(227, 244, 238)
+ } else {
+ CANVAS
+ })
+ .stroke(egui::Stroke::NONE)
+ .corner_radius(20)
+ .inner_margin(egui::Margin::symmetric(14, 7))
+ .show(ui, |ui| {
+ ui.label(
+ RichText::new(if unlocked {
+ "Vault unlocked"
+ } else {
+ "Vault locked"
+ })
+ .size(12.0)
+ .strong()
+ .color(if unlocked {
+ ACCENT
+ } else {
+ MUTED
+ }),
+ );
+ });
});
});
- ui.add_space(15.0);
+ ui.label(RichText::new(subtitle).color(MUTED));
+ ui.add_space(22.0);
if !self.message.is_empty() {
- let color = if self.is_error {
- Color32::from_rgb(255, 166, 166)
- } else {
- ACCENT
- };
- ui.group(|ui| {
- ui.label(RichText::new(&self.message).color(color));
- });
- ui.add_space(14.0);
+ card(ui)
+ .fill(if self.is_error {
+ Color32::from_rgb(255, 240, 240)
+ } else {
+ Color32::from_rgb(231, 246, 240)
+ })
+ .show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.label(RichText::new(&self.message).color(if self.is_error {
+ DANGER
+ } else {
+ ACCENT
+ }));
+ });
+ ui.add_space(12.0);
}
}
fn overview(&mut self, ui: &mut egui::Ui) {
- egui::Frame::group(ui.style())
- .fill(Color32::from_rgb(29, 53, 43))
- .show(ui, |ui| {
- ui.set_min_width(690.0);
- ui.set_min_height(135.0);
- ui.heading(RichText::new("Sırlarınız kontrolünüzde.").size(29.0).color(ACCENT));
- ui.label("Kasadaki değerleri yerel hedeflere güvenli biçimde eşleyin. Sır içerikleri arayüzde geri gösterilmez.");
- ui.add_space(9.0);
- if ui.button("Yeni eşleme oluştur").clicked() { self.page = Page::Bindings; self.editing = true; self.form = BindingForm::default(); }
- });
- ui.add_space(16.0);
- let state = self.engine.status();
- ui.horizontal(|ui| {
- ui.group(|ui| {
- ui.set_min_width(150.0);
- ui.set_min_height(76.0);
- ui.label("EŞLEME");
- ui.heading(self.engine.config.bindings.len().to_string());
- });
- ui.group(|ui| {
- ui.set_min_width(150.0);
- ui.set_min_height(76.0);
- ui.label("KASA");
- ui.heading(if self.source.is_unlocked() {
- "Açık"
- } else {
- "Kilitli"
- });
+ card(ui).fill(Color32::from_rgb(230, 242, 238)).stroke(egui::Stroke::NONE)
+ .inner_margin(28).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.label(RichText::new("LOCAL FIRST. ALWAYS IN YOUR CONTROL.").size(10.0).strong().color(ACCENT));
+ ui.add_space(10.0);
+ ui.label(RichText::new("Your secrets. Your workspace.").size(32.0).strong().color(INK));
+ ui.label(RichText::new("Keep your files, containers, and credentials in sync\nwith your local vault or Azure Key Vault.").size(15.0).color(MUTED));
+ ui.add_space(12.0);
+ if primary_button(ui, "Create binding").clicked() {
+ self.page = Page::Bindings;
+ self.editing = true;
+ self.form = BindingForm::default();
+ }
});
- ui.group(|ui| {
- ui.set_min_width(185.0);
- ui.set_min_height(76.0);
- ui.label("DİKKAT GEREKTİREN");
- let count = self
- .engine
- .config
- .bindings
- .iter()
- .filter(|binding| {
- state.disabled.contains(&binding.id)
- || state.status.get(&binding.id).is_some_and(|s| {
- matches!(
- s.outcome,
- SyncOutcome::Failed(_) | SyncOutcome::RestartRequired
- )
- })
+ ui.add_space(20.0);
+ let state = self.engine.status();
+ let attention = self
+ .engine
+ .config
+ .bindings
+ .iter()
+ .filter(|binding| {
+ state.disabled.contains(&binding.id)
+ || state.status.get(&binding.id).is_some_and(|s| {
+ matches!(
+ s.outcome,
+ SyncOutcome::Failed(_) | SyncOutcome::RestartRequired
+ )
})
- .count();
- ui.heading(count.to_string());
+ })
+ .count();
+ ui.columns(3, |columns| {
+ metric(
+ &mut columns[0],
+ "SYNC BINDINGS",
+ &self.engine.config.bindings.len().to_string(),
+ "Connected local targets",
+ INK,
+ );
+ metric(
+ &mut columns[1],
+ "LOCAL VAULT STATUS",
+ if self.source.is_unlocked() {
+ "Unlocked"
+ } else {
+ "Locked"
+ },
+ if self.source.is_unlocked() {
+ "Ready to sync"
+ } else {
+ "Unlock to access secrets"
+ },
+ ACCENT,
+ );
+ metric(
+ &mut columns[2],
+ "NEEDS ATTENTION",
+ &attention.to_string(),
+ "Errors or pending restarts",
+ if attention == 0 { INK } else { DANGER },
+ );
+ });
+ ui.add_space(24.0);
+ ui.horizontal(|ui| {
+ ui.heading("Binding health");
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui.link("View all bindings").clicked() {
+ self.page = Page::Bindings;
+ }
});
});
- ui.add_space(22.0);
- ui.heading("Eşleme durumu");
- ui.separator();
- if self.engine.config.bindings.is_empty() {
- ui.label(
- RichText::new("Henüz eşleme yok. Önce bir kasa kaydı, ardından eşleme oluşturun.")
- .color(MUTED),
- );
- }
- for binding in &self.engine.config.bindings {
- let outcome = state
- .status
- .get(&binding.id)
- .map(|s| format!("{:?}", s.outcome))
- .unwrap_or_else(|| "Bekliyor".into());
- ui.horizontal(|ui| {
- ui.strong(&binding.id);
+ ui.add_space(8.0);
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ if self.engine.config.bindings.is_empty() {
+ ui.add_space(8.0);
+ ui.strong("Start with your first secret");
ui.label(
- RichText::new(format!(
- "{} / {}",
- binding.source.entry,
- target_label(&binding.target)
- ))
+ RichText::new(
+ "Create or unlock your vault, add a secret, then connect a local target.",
+ )
.color(MUTED),
);
- ui.label(outcome);
+ ui.add_space(8.0);
+ if ui.button("Open vault").clicked() {
+ self.page = Page::Vault;
+ }
+ ui.add_space(8.0);
+ } else {
+ egui::Grid::new("binding_health")
+ .num_columns(3)
+ .spacing([30.0, 16.0])
+ .show(ui, |ui| {
+ for label in ["BINDING", "TARGET", "STATUS"] {
+ ui.label(RichText::new(label).size(10.0).strong().color(MUTED));
+ }
+ ui.end_row();
+ for binding in &self.engine.config.bindings {
+ ui.strong(&binding.id);
+ ui.label(target_label(&binding.target));
+ let outcome = if state.disabled.contains(&binding.id) {
+ "Disabled".into()
+ } else {
+ state
+ .status
+ .get(&binding.id)
+ .map(|s| outcome_label(&s.outcome).to_owned())
+ .unwrap_or_else(|| "Pending".into())
+ };
+ ui.label(outcome);
+ ui.end_row();
+ }
+ });
+ }
+ });
+ }
+ fn select_source(&mut self, id: &str) -> Result<(), core::ErrorCategory> {
+ if self.pending_vault.is_some() {
+ return Err(core::ErrorCategory::State);
+ }
+ let source = self.sources.file(id)?;
+ self.passphrase.zeroize();
+ self.secret_text.zeroize();
+ self.secret_file.clear();
+ self.entry_id.clear();
+ self.backup_path.clear();
+ self.import_path.clear();
+ self.import_prefix.clear();
+ self.import_consent = false;
+ self.pending_delete_entry = None;
+ self.source = source;
+ self.selected_source = id.into();
+ self.page = Page::Vault;
+ Ok(())
+ }
+ fn remove_source(&mut self, id: &str) -> Result<(), core::ErrorCategory> {
+ if self.pending_vault.is_some()
+ || self.pending_sync.is_some()
+ || self
+ .engine
+ .config
+ .bindings
+ .iter()
+ .any(|b| b.source.connection == id)
+ {
+ return Err(core::ErrorCategory::InvalidConfig);
+ }
+ self.sources.remove(id)?;
+ if self.selected_source == id {
+ self.select_source("local")?;
+ }
+ Ok(())
+ }
+ fn source_connections(&mut self, ui: &mut egui::Ui) {
+ let connections = match self.sources.list() {
+ Ok(connections) => connections,
+ Err(_) => {
+ self.error("Could not read source connections.");
+ return;
+ }
+ };
+ ui.label("Connections store references only. Each file-system vault has its own passphrase and lock state.");
+ if ui.button("Open workspace vault (local)").clicked()
+ && self.select_source("local").is_err()
+ {
+ self.error("Could not select the vault.");
+ }
+ ui.add_space(12.0);
+ for connection in connections {
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.strong(&connection.id);
+ ui.label(connection.location.label());
+ if let Location::FileSystem { path } = &connection.location {
+ ui.label(path.display().to_string());
+ }
+ ui.horizontal(|ui| {
+ if connection.location.store() != StoreKind::Azure
+ && ui.button("Manage vault").clicked()
+ && self.select_source(&connection.id).is_err()
+ {
+ self.error("Could not select the vault.");
+ }
+ if ui.button("Create binding").clicked() {
+ self.form = BindingForm {
+ source_kind: connection.location.store(),
+ connection: connection.id.clone(),
+ ..BindingForm::default()
+ };
+ self.editing = true;
+ self.page = Page::Bindings;
+ }
+ let used = self
+ .engine
+ .config
+ .bindings
+ .iter()
+ .any(|b| b.source.connection == connection.id);
+ if ui
+ .add_enabled(
+ !used && self.pending_sync.is_none(),
+ egui::Button::new("Remove connection"),
+ )
+ .clicked()
+ {
+ match self.remove_source(&connection.id) {
+ Ok(()) => {
+ self.notice("Connection removed. The vault file was not deleted.")
+ }
+ Err(_) => self.error("Could not remove this connection."),
+ }
+ }
+ if used {
+ ui.label("Used by bindings");
+ }
+ if matches!(connection.location, Location::Drive { .. }) && ui.button("Disconnect account").clicked() {
+ match self.sources.file(&connection.id).and_then(|source| source.disconnect().map_err(|_| core::ErrorCategory::Unauthorized)) {
+ Ok(()) => self.notice("Account disconnected and vault locked. Reconnect with the same connection ID and vault file."),
+ Err(_) => self.error("Could not remove the account credential from the OS store."),
+ }
+ }
+ });
});
- ui.separator();
+ ui.add_space(8.0);
+ }
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading("Add source connection");
+ ui.horizontal(|ui| {
+ ui.selectable_value(&mut self.connection_azure, false, "File System");
+ ui.selectable_value(&mut self.connection_azure, true, "Azure Key Vault");
+ });
+ field(ui, if self.connection_azure { "Azure vault name" } else { "Connection ID" }, &mut self.connection_id);
+ if !self.connection_azure { field(ui, "Absolute encrypted vault file path", &mut self.connection_path); }
+ if primary_button(ui, "Add connection").clicked() {
+ let location = if self.connection_azure { Location::AzureKeyVault { vault_name: self.connection_id.clone() } }
+ else { Location::FileSystem { path: self.connection_path.clone().into() } };
+ let connection = Connection { id: self.connection_id.clone(), location };
+ // Never repoint an existing binding by introducing an alias with the same ID.
+ if self.engine.config.bindings.iter().any(|b| b.source.connection == connection.id && b.source.store != connection.location.store()) {
+ self.error("This connection ID is already used by another source type.");
+ } else {
+ match self.sources.add(connection) {
+ Ok(()) => { self.connection_id.clear(); self.connection_path.clear(); self.notice("Connection added. Open Manage vault to create or unlock a file-system vault."); }
+ Err(_) => self.error("Could not add the connection. Check its unique ID and absolute path; the parent folder must exist."),
+ }
+ }
+ }
+ ui.label("Removing a connection never deletes its vault.");
+ });
+ if let Some(connection) = self.drive_form.show(ui) {
+ let existing = self
+ .sources
+ .list()
+ .is_ok_and(|connections| connections.iter().any(|c| c.id == connection.id));
+ let result = if existing {
+ self.sources.reconnect(connection)
+ } else {
+ self.sources.add(connection)
+ };
+ match result {
+ Ok(()) => {
+ self.drive_form.saved();
+ self.notice("Cloud vault added. Open Manage vault to unlock it.");
+ }
+ Err(_) => self.error("Could not add cloud connection. Use a unique connection ID."),
+ }
}
}
fn vault(&mut self, ui: &mut egui::Ui) {
- ui.label(RichText::new("Parola ve sır değerleri yalnızca geçici bellekte işlenir. 15 dakika kullanılmayan kasa kilitlenir.").color(MUTED));
+ ui.label(format!("Selected source: {}", self.selected_source));
+ ui.label(RichText::new("Manage encrypted secrets and backups. Your vault locks after 15 minutes of inactivity.").color(MUTED));
ui.add_space(14.0);
- ui.group(|ui| {
- ui.heading("Kasa erişimi");
- ui.horizontal(|ui| {
- ui.label("Kasa parolası");
- ui.add(
- egui::TextEdit::singleline(&mut *self.passphrase)
- .password(true)
- .desired_width(300.0),
- );
- });
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading("Vault access");
+ secret_field(ui, "Passphrase", &mut self.passphrase);
ui.horizontal(|ui| {
- if !self.source.exists() && ui.button("Kasa oluştur").clicked() {
+ if !self.source.exists() && primary_button(ui, "Create vault").clicked() {
self.vault_action("create");
}
- if self.source.exists() && ui.button("Kilidi aç").clicked() {
+ if self.source.exists() && primary_button(ui, "Unlock vault").clicked() {
self.vault_action("unlock");
}
- if ui.button("Kilitle").clicked() {
+ if ui.button("Lock vault").clicked() {
self.source.lock();
self.passphrase.zeroize();
- self.notice("Kasa kilitlendi");
+ self.notice("Vault locked.");
}
});
});
ui.add_space(12.0);
- ui.group(|ui| {
- ui.heading("Şifreli yedek");
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading("Encrypted backup");
+ field(ui, "Backup file path", &mut self.backup_path);
ui.horizontal(|ui| {
- ui.label("Yedek dosyası yolu");
- ui.add(egui::TextEdit::singleline(&mut self.backup_path).desired_width(420.0));
- });
- ui.horizontal(|ui| {
- if ui.button("Yedek oluştur").clicked() {
- match self.source.backup(&PathBuf::from(&self.backup_path)) {
- Ok(()) => self.notice("Şifreli yedek oluşturuldu"),
- Err(_) => self.error("Yedek oluşturulamadı"),
- }
+ if ui.button("Create backup").clicked() {
+ let path = PathBuf::from(&self.backup_path);
+ self.run_vault(move |source| {
+ source
+ .backup(&path)
+ .map(|_| "Encrypted backup created.".into())
+ .map_err(|error| format!("Could not create backup: {error}"))
+ });
}
- if ui.button("Yedekten kurtar").clicked() {
+ if ui.button("Restore backup").clicked() {
self.vault_action("recover");
}
});
- ui.label(RichText::new("Kurtarma mevcut kasanın üzerine yazmaz.").color(MUTED));
+ ui.label(
+ RichText::new("Restore is available only when no vault exists in this workspace.")
+ .color(MUTED),
+ );
});
ui.add_space(17.0);
- ui.heading("Kasa kayıtları");
+ ui.heading("Saved secrets");
ui.separator();
match self.source.entries() {
Ok(entries) if entries.is_empty() => {
- ui.label(RichText::new("Kasa boş.").color(MUTED));
+ ui.label(
+ RichText::new("No secrets yet. Add your first secret below.").color(MUTED),
+ );
}
Ok(entries) => {
let mut remove = None;
for entry in entries {
ui.horizontal(|ui| {
ui.strong(&entry.id);
- ui.label(format!("{:?}", entry.kind));
+ ui.label(secret_type_label(&entry.kind));
ui.label(
RichText::new(format!(
- "sürüm {}",
+ "Version {}",
&entry.version[..8.min(entry.version.len())]
))
.color(MUTED),
);
if self.pending_delete_entry.as_deref() == Some(entry.id.as_str()) {
- ui.label(RichText::new("Silinsin mi?").color(Color32::LIGHT_RED));
- if ui.button("Evet, sil").clicked() {
+ ui.label(RichText::new("Remove this item?").color(DANGER));
+ if ui.button("Confirm removal").clicked() {
remove = Some(entry.id.clone());
}
- if ui.button("Vazgeç").clicked() {
+ if ui.button("Cancel").clicked() {
self.pending_delete_entry = None;
}
- } else if ui.button("Kaldır").clicked() {
+ } else if ui.button("Remove").clicked() {
self.pending_delete_entry = Some(entry.id.clone());
}
});
@@ -669,41 +980,65 @@ impl DesktopApp {
}
if let Some(id) = remove {
self.pending_delete_entry = None;
- match self.source.remove_entry(&id) {
- Ok(()) => self.notice("Kasa kaydı kaldırıldı"),
- Err(_) => self.error("Kasa kaydı kaldırılamadı"),
- }
+ self.run_vault(move |source| source.remove_entry(&id).map(|_| "Secret removed.".into()).map_err(|error| format!("Could not remove secret: {error}. On conflict, unlock again to reload.")));
}
}
Err(_) => {
- ui.label(RichText::new("Kayıtları görmek için kasayı açın.").color(MUTED));
+ ui.label(
+ RichText::new("Unlock your vault to view saved secret names.").color(MUTED),
+ );
}
}
ui.add_space(16.0);
- ui.group(|ui| {
- ui.heading("Sır ekle veya döndür");
- field(ui, "Kayıt kimliği", &mut self.entry_id);
- secret_type_combo(ui, "Sır türü", &mut self.entry_kind);
- ui.horizontal(|ui| { ui.label("Sır değeri"); ui.add(egui::TextEdit::singleline(&mut *self.secret_text).password(true).desired_width(390.0)); });
- ui.label(RichText::new("Çok satırlı veya ikili değer için dosya yolu kullanın; mevcut sır geri gösterilmez.").color(MUTED));
- field(ui, "Sır dosyası (isteğe bağlı)", &mut self.secret_file);
- if ui.button("Kasaya kaydet").clicked() { self.put_entry(); }
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading("Add or rotate a secret");
+ field(ui, "Secret ID", &mut self.entry_id);
+ secret_type_combo(ui, "Secret type", &mut self.entry_kind);
+ secret_field(ui, "Secret value", &mut self.secret_text);
+ ui.label(RichText::new("Use a file for multiline or binary values. Saved secret values are never displayed.").color(MUTED));
+ field(ui, "Secret file path (optional)", &mut self.secret_file);
+ if primary_button(ui, "Save secret").clicked() { self.put_entry(); }
+ });
+ ui.add_space(12.0);
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading("Import browser passwords");
+ ui.label("One-way import from an Edge or Chrome CSV you explicitly exported. No browser profile is accessed or changed.");
+ ui.label("Each row becomes an encrypted JSON record with its URL, username and password. Existing entries are never overwritten.");
+ field(ui, "CSV file path", &mut self.import_path);
+ field(ui, "Unique import prefix", &mut self.import_prefix);
+ ui.checkbox(&mut self.import_consent, "I approve importing this file and understand the source CSV contains plaintext passwords.");
+ if ui.add_enabled(self.import_consent && self.source.is_unlocked(), egui::Button::new("Import CSV into vault")).clicked() {
+ self.import_browser_csv();
+ }
+ ui.label(RichText::new("Maximum 8 MiB / 1,000 records. The source file is never deleted automatically. Reimporting with the same prefix is rejected.").color(MUTED));
});
}
fn bindings(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(
- RichText::new(
- "Kasa kaydını dosya, Docker girdisi veya Git kimlik bilgisine bağlayın.",
- )
- .color(MUTED),
+ RichText::new("Connect a vault secret to a file, Docker input, or Git credential.")
+ .color(MUTED),
);
- if ui.button("+ Yeni eşleme").clicked() {
+ if primary_button(ui, "+ New binding").clicked() {
self.form = BindingForm::default();
self.editing = true;
}
});
ui.add_space(12.0);
+ if self.engine.config.bindings.is_empty() && !self.editing {
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.strong("No bindings yet");
+ ui.label(
+ RichText::new(
+ "Create a binding to keep a local target in sync with a vault secret.",
+ )
+ .color(MUTED),
+ );
+ });
+ }
let state = self.engine.status();
let mut edit = None;
let mut remove = None;
@@ -711,7 +1046,8 @@ impl DesktopApp {
let mut enable = None;
let mut ack = None;
for binding in &self.engine.config.bindings {
- ui.group(|ui| {
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.strong(&binding.id);
ui.label(
@@ -723,38 +1059,38 @@ impl DesktopApp {
.color(MUTED),
);
if state.disabled.contains(&binding.id) {
- ui.colored_label(Color32::from_rgb(239, 190, 115), "Devre dışı");
+ ui.colored_label(Color32::from_rgb(239, 190, 115), "Disabled");
} else if let Some(status) = state.status.get(&binding.id) {
- ui.label(format!("{:?}", status.outcome));
+ ui.label(outcome_label(&status.outcome));
}
});
ui.horizontal(|ui| {
- if ui.button("Düzenle").clicked() {
+ if ui.button("Edit").clicked() {
edit = Some(binding.clone());
}
- if ui.button("Şimdi kontrol et").clicked() {
+ if ui.button("Sync now").clicked() {
sync = Some(binding.id.clone());
}
- if state.disabled.contains(&binding.id) && ui.button("Etkinleştir").clicked() {
+ if state.disabled.contains(&binding.id) && ui.button("Enable").clicked() {
enable = Some(binding.id.clone());
}
if state
.status
.get(&binding.id)
.is_some_and(|s| s.outcome == SyncOutcome::RestartRequired)
- && ui.button("Yeniden başlatmayı onayla").clicked()
+ && ui.button("Acknowledge restart").clicked()
{
ack = Some(binding.id.clone());
}
if self.pending_delete_binding.as_deref() == Some(binding.id.as_str()) {
- ui.label(RichText::new("Silinsin mi?").color(Color32::LIGHT_RED));
- if ui.button("Evet, sil").clicked() {
+ ui.label(RichText::new("Remove this item?").color(DANGER));
+ if ui.button("Confirm removal").clicked() {
remove = Some(binding.id.clone());
}
- if ui.button("Vazgeç").clicked() {
+ if ui.button("Cancel").clicked() {
self.pending_delete_binding = None;
}
- } else if ui.button("Kaldır").clicked() {
+ } else if ui.button("Remove").clicked() {
self.pending_delete_binding = Some(binding.id.clone());
}
});
@@ -769,15 +1105,27 @@ impl DesktopApp {
self.sync_binding(&id);
}
if let Some(id) = enable {
- match self.runtime.block_on(self.engine.enable_binding(&id)) {
- Ok(()) => self.notice("Eşleme etkinleştirildi"),
- Err(_) => self.error("Eşleme etkinleştirilemedi"),
+ if self.pending_sync.is_none() {
+ let engine = self.engine.clone();
+ self.pending_sync = Some(self.runtime.spawn(async move {
+ match engine.enable_binding(&id).await {
+ Ok(()) => SyncOutcome::Unchanged,
+ Err(error) => SyncOutcome::Failed(error),
+ }
+ }));
+ self.notice("Enabling binding…");
}
}
if let Some(id) = ack {
- match self.runtime.block_on(self.engine.acknowledge_restart(&id)) {
- Ok(()) => self.notice("Yeniden başlatma onaylandı"),
- Err(_) => self.error("Onay başarısız"),
+ if self.pending_sync.is_none() {
+ let engine = self.engine.clone();
+ self.pending_sync = Some(self.runtime.spawn(async move {
+ match engine.acknowledge_restart(&id).await {
+ Ok(()) => SyncOutcome::Unchanged,
+ Err(error) => SyncOutcome::Failed(error),
+ }
+ }));
+ self.notice("Acknowledging restart…");
}
}
if let Some(id) = remove {
@@ -790,49 +1138,102 @@ impl DesktopApp {
}
}
fn binding_editor(&mut self, ui: &mut egui::Ui) {
- ui.group(|ui| {
- ui.heading(if self.form.original_id.is_some() { "Eşlemeyi düzenle" } else { "Yeni eşleme" });
- field(ui, "Eşleme kimliği", &mut self.form.id);
- field(ui, "Kasa kayıt kimliği", &mut self.form.entry);
- secret_type_combo(ui, "Sır türü", &mut self.form.secret_type);
- egui::ComboBox::from_label("Hedef türü").selected_text(self.form.target_kind.label()).show_ui(ui, |ui| {
- for choice in TargetChoice::ALL { ui.selectable_value(&mut self.form.target_kind, choice, choice.label()); }
- });
- if self.form.target_kind == TargetChoice::GitCredential {
- field(ui, "Git host", &mut self.form.host);
- field(ui, "Depo yolu (isteğe bağlı)", &mut self.form.git_path);
- field(ui, "Git kullanıcı adı", &mut self.form.username);
- } else {
- field(ui, "Hedef dosyanın mutlak yolu", &mut self.form.path);
- if self.form.target_kind != TargetChoice::WholeFile { field(ui, "Alan anahtarı (JSON/YAML için /path)", &mut self.form.key); }
- if self.form.target_kind == TargetChoice::DockerEnvFile {
- field(ui, "Compose dosyasının mutlak yolu", &mut self.form.compose_path);
- field(ui, "Compose servisi", &mut self.form.service);
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.heading(if self.form.original_id.is_some() { "Edit binding" } else { "New binding" });
+ ui.label(RichText::new("Choose a secret, a destination, and a sync schedule.").color(MUTED));
+ ui.add_space(12.0);
+ ui.columns(2, |columns| {
+ let ui = &mut columns[0];
+ ui.label(RichText::new("01 SOURCE").size(11.0).strong().color(ACCENT));
+ field(ui, "Binding ID", &mut self.form.id);
+ egui::ComboBox::from_id_salt("source_kind")
+ .selected_text(match self.form.source_kind { StoreKind::Azure => "Azure Key Vault", StoreKind::OneDrive => "OneDrive", StoreKind::GoogleDrive => "Google Drive", _ => "Local vault" })
+ .show_ui(ui, |ui| {
+ ui.selectable_value(&mut self.form.source_kind, StoreKind::LocalVault, "Local vault");
+ ui.selectable_value(&mut self.form.source_kind, StoreKind::Azure, "Azure Key Vault");
+ ui.selectable_value(&mut self.form.source_kind, StoreKind::OneDrive, "OneDrive");
+ ui.selectable_value(&mut self.form.source_kind, StoreKind::GoogleDrive, "Google Drive");
+ });
+ if self.form.source_kind == StoreKind::Azure {
+ field(ui, "Azure vault name", &mut self.form.connection);
+ field(ui, "Azure secret name", &mut self.form.entry);
+ self.form.secret_type = SecretType::Text;
+ ui.label("Text secrets • Azure public cloud");
+ ui.label("Uses your existing Azure CLI sign-in. Requires secrets/list and secrets/get. Values are fetched only when the version changes.");
+ } else {
+ let connections = match self.sources.list() {
+ Ok(connections) => connections,
+ Err(_) => { ui.colored_label(DANGER, "Could not load source connections."); return; }
+ };
+ egui::ComboBox::from_id_salt("binding_source_connection")
+ .selected_text(&self.form.connection).show_ui(ui, |ui| {
+ if self.form.source_kind == StoreKind::LocalVault { ui.selectable_value(&mut self.form.connection, "local".into(), "local (workspace vault)"); }
+ for connection in connections {
+ if connection.location.store() == self.form.source_kind {
+ ui.selectable_value(&mut self.form.connection, connection.id.clone(), &connection.id);
+ }
+ }
+ });
+ field(ui, "Vault secret ID", &mut self.form.entry);
+ secret_type_combo(ui, "Secret type", &mut self.form.secret_type);
}
- }
- egui::ComboBox::from_label("Eksik hedef politikası")
- .selected_text(policy_label(&self.form.policy)).show_ui(ui, |ui| {
- ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Alert, "Uyar");
- ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Recreate, "Yeniden oluştur");
- ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Disable, "Devre dışı bırak");
- });
- field(ui, "Kontrol aralığı (saniye)", &mut self.form.interval);
- ui.label(RichText::new("Dosya yolları çalışma alanı kökü içinde olmalıdır. Git depo yolu için credential.useHttpPath etkin olmalıdır.").color(MUTED));
- ui.horizontal(|ui| { if ui.button("Eşlemeyi kaydet").clicked() { self.save_binding(); }
- if ui.button("Vazgeç").clicked() { self.editing = false; } });
+ ui.add_space(18.0);
+ ui.label(RichText::new("03 SYNC SCHEDULE").size(11.0).strong().color(ACCENT));
+ field(ui, "Check interval (seconds)", &mut self.form.interval);
+ ui.label(RichText::new("When the target is missing").size(12.0).strong());
+ egui::ComboBox::from_id_salt("missing_target_policy")
+ .width(ui.available_width())
+ .selected_text(policy_label(&self.form.policy)).show_ui(ui, |ui| {
+ ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Alert, "Alert");
+ ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Recreate, "Recreate");
+ ui.selectable_value(&mut self.form.policy, MissingTargetPolicy::Disable, "Disable binding");
+ });
+ let ui = &mut columns[1];
+ ui.label(RichText::new("02 DESTINATION").size(11.0).strong().color(ACCENT));
+ ui.label(RichText::new("Target type").size(12.0).strong());
+ egui::ComboBox::from_id_salt("target_type")
+ .width(ui.available_width())
+ .selected_text(self.form.target_kind.label()).show_ui(ui, |ui| {
+ for choice in TargetChoice::ALL {
+ ui.selectable_value(&mut self.form.target_kind, choice, choice.label());
+ }
+ });
+ if self.form.target_kind == TargetChoice::GitCredential {
+ field(ui, "Git host", &mut self.form.host);
+ field(ui, "Repository path (optional)", &mut self.form.git_path);
+ field(ui, "Git username", &mut self.form.username);
+ } else {
+ field(ui, "Absolute target file path", &mut self.form.path);
+ if self.form.target_kind != TargetChoice::WholeFile {
+ field(ui, "Field key (use /path for JSON or YAML)", &mut self.form.key);
+ }
+ if self.form.target_kind == TargetChoice::DockerEnvFile {
+ field(ui, "Absolute Compose file path", &mut self.form.compose_path);
+ field(ui, "Compose service", &mut self.form.service);
+ }
+ }
+ ui.add_space(8.0);
+ ui.label(RichText::new("File targets must be inside your workspace. Repository-specific Git credentials require credential.useHttpPath.").small().color(MUTED));
+ });
+ ui.add_space(18.0);
+ ui.separator();
+ ui.horizontal(|ui| {
+ if primary_button(ui, "Save binding").clicked() { self.save_binding(); }
+ if ui.button("Cancel").clicked() { self.editing = false; }
+ });
});
}
fn events(&mut self, ui: &mut egui::Ui) {
ui.label(
- RichText::new("Olaylar yalnızca kimlik, zaman ve güvenli sonuç kategorilerini içerir.")
- .color(MUTED),
+ RichText::new("Recent sync events. Secret values are never included.").color(MUTED),
);
ui.add_space(10.0);
let data = match fs::read_to_string(&self.engine.events_path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(_) => {
- ui.colored_label(Color32::LIGHT_RED, "Olaylar okunamadı");
+ ui.colored_label(DANGER, "Could not read activity.");
return;
}
};
@@ -844,38 +1245,47 @@ impl DesktopApp {
.collect();
match events {
Ok(events) if events.is_empty() => {
- ui.label("Henüz olay yok.");
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.strong("Nothing to report yet");
+ ui.label(
+ RichText::new("Your sync history will appear here after the first check.")
+ .color(MUTED),
+ );
+ });
}
Ok(events) => {
for event in events {
ui.horizontal(|ui| {
ui.label(RichText::new(event.timestamp.to_string()).color(MUTED));
ui.strong(event.binding_id);
- ui.label(format!("{:?}", event.outcome));
+ ui.label(outcome_label(&event.outcome));
});
ui.separator();
}
}
Err(_) => {
- ui.colored_label(Color32::LIGHT_RED, "Olay kaydı geçersiz");
+ ui.colored_label(DANGER, "An activity record is invalid.");
}
}
}
fn settings(&mut self, ui: &mut egui::Ui) {
- ui.heading("Çalışma alanı");
- ui.label(RichText::new("Hedef dosyalar seçilen kökün içinde olmalıdır.").color(MUTED));
- field(ui, "Kök klasör", &mut self.root_input);
- if ui.button("Bu çalışma alanına geç").clicked() {
+ ui.heading("Workspace");
+ ui.label(
+ RichText::new("Choose the workspace that contains your target files.").color(MUTED),
+ );
+ field(ui, "Workspace folder", &mut self.root_input);
+ if primary_button(ui, "Switch workspace").clicked() {
let requested = PathBuf::from(&self.root_input);
match requested.canonicalize() {
- Ok(path) if path == self.root => self.notice("Bu çalışma alanı zaten açık"),
+ Ok(path) if path == self.root => self.notice("This workspace is already open."),
Ok(path) if path.is_dir() => {
let lock_path = path.join("local/agent.lock");
if fs::create_dir_all(path.join("local")).is_err()
|| core::acquire_agent_lock(&lock_path).is_err()
{
self.error(
- "Çalışma alanı başka bir ajan tarafından kullanılıyor veya yazılamıyor",
+ "The workspace is in use by another agent or cannot be written.",
);
return;
}
@@ -890,77 +1300,223 @@ impl DesktopApp {
if started.is_some() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
} else {
- self.error("Yeni çalışma alanı açılamadı");
+ self.error("Could not open the workspace.");
}
}
- _ => self.error("Kök klasör bulunamadı"),
+ _ => self.error("Workspace folder not found."),
}
}
ui.add_space(18.0);
- ui.heading("Yerel yönetim");
- ui.label("Bu pencere doğrudan çalışan Rust uygulamasına bağlıdır; HTTP sunucusu veya tarayıcı kullanılmaz.");
- ui.label(
- "Pencere kapandığında yönetim süreci durur. Ayrı 'agent' modu bağımsız çalışabilir.",
- );
+ ui.heading("Desktop agent");
+ ui.label("Scheduled sync runs locally while this window is open.");
+ ui.label("The separate CLI agent is development-only and does not support multiple source connections. Background service controls are not available yet.");
+ ui.label("Azure bindings currently require the desktop window to stay open. Sign in with Azure CLI before syncing; no Azure credentials are saved in workspace settings.");
ui.add_space(18.0);
- ui.group(|ui| { ui.strong("Geliştirme sürümü");
- ui.label("Yerel GUI ve çekirdek işlevleri kullanılabilir; paketleme ve üç işletim sistemi üzerinde yerel entegrasyon onayı hâlâ gerekir."); });
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width()); ui.strong("Development build");
+ ui.label("Native desktop management is available. Release packaging and platform validation are in progress."); });
}
}
impl eframe::App for DesktopApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ if self
+ .pending_vault
+ .as_ref()
+ .is_some_and(JoinHandle::is_finished)
+ {
+ match self.runtime.block_on(self.pending_vault.take().unwrap()) {
+ Ok(Ok(message)) => self.notice(&message),
+ Ok(Err(message)) => self.error(&message),
+ Err(_) => self.error("Vault operation stopped unexpectedly."),
+ }
+ }
+ if self.pending_vault.is_some() {
+ ctx.request_repaint_after(std::time::Duration::from_millis(100));
+ }
+ if self
+ .pending_sync
+ .as_ref()
+ .is_some_and(JoinHandle::is_finished)
+ {
+ let result = self.runtime.block_on(self.pending_sync.take().unwrap());
+ match result {
+ Ok(SyncOutcome::Failed(_)) | Err(_) => {
+ self.error("Sync failed. Check the binding status and activity log.")
+ }
+ Ok(_) => self.notice("Sync check complete."),
+ }
+ }
+ if self.pending_sync.is_some() {
+ ctx.request_repaint_after(std::time::Duration::from_millis(100));
+ }
egui::SidePanel::left("nav")
- .min_width(185.0)
- .max_width(185.0)
+ .resizable(false)
+ .exact_width(218.0)
+ .frame(egui::Frame::NONE.fill(NAV).inner_margin(20))
.show(ctx, |ui| {
- ui.add_space(15.0);
- ui.heading(RichText::new("TAR").color(ACCENT));
- ui.label(RichText::new("VAULT SYNC").small().color(MUTED));
- ui.add_space(28.0);
+ ui.add_space(12.0);
+ ui.horizontal(|ui| {
+ egui::Frame::NONE
+ .fill(CANVAS)
+ .corner_radius(10)
+ .inner_margin(4)
+ .show(ui, |ui| {
+ if let Some(texture) = &self.brand_texture {
+ ui.add(
+ egui::Image::new((texture.id(), egui::vec2(48.0, 48.0)))
+ .alt_text("TAR Vault Sync logo"),
+ );
+ }
+ });
+ ui.vertical(|ui| {
+ ui.label(
+ RichText::new("TAR")
+ .size(20.0)
+ .strong()
+ .color(Color32::WHITE),
+ );
+ ui.label(
+ RichText::new("VAULT SYNC")
+ .size(10.0)
+ .color(Color32::from_rgb(149, 172, 191)),
+ );
+ });
+ });
+ ui.add_space(38.0);
+ ui.label(
+ RichText::new("WORKSPACE")
+ .size(10.0)
+ .strong()
+ .color(Color32::from_rgb(121, 144, 165)),
+ );
+ ui.add_space(8.0);
for (page, name) in [
- (Page::Overview, "Genel Bakış"),
- (Page::Vault, "Kasa"),
- (Page::Bindings, "Eşlemeler"),
- (Page::Events, "Olaylar"),
- (Page::Settings, "Yönetim"),
+ (Page::Overview, "Overview"),
+ (Page::Sources, "Sources"),
+ (Page::Vault, "Vault"),
+ (Page::Bindings, "Bindings"),
+ (Page::Events, "Activity"),
+ (Page::Settings, "Settings"),
] {
- if ui.selectable_label(self.page == page, name).clicked() {
+ let selected = self.page == page;
+ let button =
+ egui::Button::new(RichText::new(name).size(14.0).color(if selected {
+ Color32::WHITE
+ } else {
+ Color32::from_rgb(168, 186, 202)
+ }))
+ .fill(if selected {
+ Color32::from_rgb(36, 64, 76)
+ } else {
+ NAV
+ })
+ .stroke(egui::Stroke::NONE)
+ .corner_radius(8);
+ if ui.add_sized([ui.available_width(), 42.0], button).clicked() {
self.page = page;
self.message.clear();
}
- ui.add_space(6.0);
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
- ui.label(RichText::new("Yerel ajan çalışıyor").color(ACCENT).small());
+ ui.hyperlink_to(
+ RichText::new("Powered by fmarslan.com")
+ .size(11.0)
+ .color(Color32::from_rgb(121, 144, 165)),
+ "https://fmarslan.com/",
+ );
+ ui.label(
+ RichText::new("Desktop / v0.1.0")
+ .size(10.0)
+ .color(Color32::from_rgb(121, 144, 165)),
+ );
+ ui.label(
+ RichText::new("Desktop scheduler active")
+ .size(12.0)
+ .color(Color32::from_rgb(109, 213, 178)),
+ );
+ ui.add_space(6.0);
+ ui.separator();
});
});
- egui::CentralPanel::default().show(ctx, |ui| {
- egui::ScrollArea::vertical().show(ui, |ui| {
- ui.add_space(8.0);
- self.header(ui);
- match self.page {
- Page::Overview => self.overview(ui),
- Page::Vault => self.vault(ui),
- Page::Bindings => self.bindings(ui),
- Page::Events => self.events(ui),
- Page::Settings => self.settings(ui),
- }
+ egui::CentralPanel::default()
+ .frame(
+ egui::Frame::NONE
+ .fill(CANVAS)
+ .inner_margin(egui::Margin::symmetric(30, 26)),
+ )
+ .show(ctx, |ui| {
+ egui::ScrollArea::vertical()
+ .auto_shrink([false, false])
+ .show(ui, |ui| {
+ self.header(ui);
+ match self.page {
+ Page::Overview => self.overview(ui),
+ Page::Sources => self.source_connections(ui),
+ Page::Vault => {
+ ui.add_enabled_ui(self.pending_vault.is_none(), |ui| {
+ self.vault(ui)
+ });
+ }
+ Page::Bindings => self.bindings(ui),
+ Page::Events => self.events(ui),
+ Page::Settings => self.settings(ui),
+ }
+ ui.add_space(20.0);
+ });
});
- });
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
}
+fn card(ui: &egui::Ui) -> egui::Frame {
+ egui::Frame::group(ui.style())
+ .fill(Color32::WHITE)
+ .stroke(egui::Stroke::new(1.0, BORDER))
+ .corner_radius(12)
+ .inner_margin(20)
+}
+fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
+ ui.add(
+ egui::Button::new(RichText::new(label).strong().color(Color32::WHITE))
+ .fill(ACCENT)
+ .stroke(egui::Stroke::NONE),
+ )
+}
+fn metric(ui: &mut egui::Ui, title: &str, value: &str, detail: &str, color: Color32) {
+ card(ui).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.label(RichText::new(title).size(10.0).strong().color(MUTED));
+ ui.label(RichText::new(value).size(27.0).strong().color(color));
+ ui.label(RichText::new(detail).size(11.0).color(MUTED));
+ });
+}
fn field(ui: &mut egui::Ui, label: &str, value: &mut String) {
- ui.horizontal(|ui| {
- ui.label(label);
- ui.add(egui::TextEdit::singleline(value).desired_width(420.0));
+ ui.push_id(label, |ui| {
+ ui.label(RichText::new(label).size(12.0).strong());
+ ui.add(
+ egui::TextEdit::singleline(value)
+ .desired_width(ui.available_width().min(580.0))
+ .margin(egui::vec2(10.0, 9.0)),
+ );
+ });
+}
+fn secret_field(ui: &mut egui::Ui, label: &str, value: &mut String) {
+ ui.push_id(label, |ui| {
+ ui.label(RichText::new(label).size(12.0).strong());
+ ui.add(
+ egui::TextEdit::singleline(value)
+ .password(true)
+ .desired_width(ui.available_width().min(580.0))
+ .margin(egui::vec2(10.0, 9.0)),
+ );
});
}
fn secret_type_combo(ui: &mut egui::Ui, label: &str, kind: &mut SecretType) {
- egui::ComboBox::from_label(label)
- .selected_text(format!("{kind:?}"))
+ ui.label(RichText::new(label).size(12.0).strong());
+ egui::ComboBox::from_id_salt(label)
+ .width(240.0)
+ .selected_text(secret_type_label(kind))
.show_ui(ui, |ui| {
for choice in [
SecretType::Text,
@@ -969,22 +1525,89 @@ fn secret_type_combo(ui: &mut egui::Ui, label: &str, kind: &mut SecretType) {
SecretType::Certificate,
SecretType::PrivateKey,
] {
- let text = format!("{choice:?}");
+ let text = secret_type_label(&choice);
ui.selectable_value(kind, choice, text);
}
});
}
+fn secret_type_label(kind: &SecretType) -> &'static str {
+ match kind {
+ SecretType::Text => "Text",
+ SecretType::Json => "JSON",
+ SecretType::Binary => "Binary",
+ SecretType::Certificate => "Certificate",
+ SecretType::PrivateKey => "Private key",
+ }
+}
+fn configure_theme(ctx: &egui::Context) {
+ let mut style = (*ctx.style()).clone();
+ style.visuals = egui::Visuals::light();
+ style.visuals.override_text_color = Some(INK);
+ style.visuals.panel_fill = CANVAS;
+ style.visuals.window_fill = Color32::WHITE;
+ style.visuals.extreme_bg_color = Color32::from_rgb(248, 250, 252);
+ style.visuals.faint_bg_color = CANVAS;
+ style.visuals.hyperlink_color = ACCENT;
+ style.visuals.selection.bg_fill = Color32::from_rgb(200, 231, 221);
+ style.visuals.selection.stroke = egui::Stroke::new(1.0, INK);
+ style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER);
+ style.visuals.widgets.inactive.bg_fill = Color32::WHITE;
+ style.visuals.widgets.inactive.weak_bg_fill = Color32::WHITE;
+ style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, BORDER);
+ style.visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(7);
+ style.visuals.widgets.hovered.bg_fill = Color32::from_rgb(235, 245, 241);
+ style.visuals.widgets.hovered.weak_bg_fill = Color32::from_rgb(235, 245, 241);
+ style.visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, ACCENT);
+ style.visuals.widgets.active.bg_fill = Color32::from_rgb(215, 237, 228);
+ style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, ACCENT);
+ style.spacing.item_spacing = egui::vec2(12.0, 10.0);
+ style.spacing.button_padding = egui::vec2(14.0, 9.0);
+ style.spacing.interact_size.y = 34.0;
+ for (kind, size) in [
+ (egui::TextStyle::Heading, 19.0),
+ (egui::TextStyle::Body, 14.0),
+ (egui::TextStyle::Button, 13.0),
+ (egui::TextStyle::Small, 11.0),
+ (egui::TextStyle::Monospace, 13.0),
+ ] {
+ let family = if kind == egui::TextStyle::Monospace {
+ egui::FontFamily::Monospace
+ } else {
+ egui::FontFamily::Proportional
+ };
+ style
+ .text_styles
+ .insert(kind, egui::FontId::new(size, family));
+ }
+ ctx.set_style(style);
+}
+fn outcome_label(outcome: &SyncOutcome) -> &'static str {
+ match outcome {
+ SyncOutcome::Unchanged => "Up to date",
+ SyncOutcome::Applied => "Synced",
+ SyncOutcome::RestartRequired => "Restart required",
+ SyncOutcome::Disabled => "Disabled",
+ SyncOutcome::Failed(category) => match category {
+ core::ErrorCategory::InvalidConfig => "Invalid configuration",
+ core::ErrorCategory::Source => "Source unavailable",
+ core::ErrorCategory::VersionConflict => "Version conflict",
+ core::ErrorCategory::Target => "Target error",
+ core::ErrorCategory::State => "State error",
+ core::ErrorCategory::Unauthorized => "Access denied",
+ },
+ }
+}
fn policy_label(policy: &MissingTargetPolicy) -> &'static str {
match policy {
- MissingTargetPolicy::Alert => "Uyar",
- MissingTargetPolicy::Recreate => "Yeniden oluştur",
- MissingTargetPolicy::Disable => "Devre dışı bırak",
+ MissingTargetPolicy::Alert => "Alert",
+ MissingTargetPolicy::Recreate => "Recreate",
+ MissingTargetPolicy::Disable => "Disable binding",
}
}
fn target_label(target: &TargetSpec) -> &'static str {
match target {
- TargetSpec::WholeFile { .. } => "Dosya",
- TargetSpec::StructuredField { .. } => "Alan",
+ TargetSpec::WholeFile { .. } => "File",
+ TargetSpec::StructuredField { .. } => "Field",
TargetSpec::DockerInput { .. } => "Docker",
TargetSpec::GitCredential { .. } => "Git",
TargetSpec::Fake { .. } => "Fake",
@@ -1021,10 +1644,16 @@ fn write_config(path: &PathBuf, bytes: &[u8]) -> std::io::Result<()> {
pub fn run(root: PathBuf) -> Result<(), Box> {
fs::create_dir_all(root.join("local"))?;
let _agent_lock = core::acquire_agent_lock(&root.join("local/agent.lock"))?;
- let app = DesktopApp::new(root)?;
+ let mut app = DesktopApp::new(root)?;
+ let icon = eframe::icon_data::from_png_bytes(BRAND_MARK)?;
+ let brand_image = egui::ColorImage::from_rgba_unmultiplied(
+ [icon.width as usize, icon.height as usize],
+ &icon.rgba,
+ );
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
- .with_inner_size([1160.0, 760.0])
+ .with_icon(icon)
+ .with_inner_size([1240.0, 840.0])
.with_min_inner_size([900.0, 600.0]),
..Default::default()
};
@@ -1032,11 +1661,12 @@ pub fn run(root: PathBuf) -> Result<(), Box> {
"TAR Vault Sync",
options,
Box::new(move |cc| {
- cc.egui_ctx.set_pixels_per_point(1.12);
- let mut visuals = egui::Visuals::dark();
- visuals.widgets.active.bg_fill = ACCENT;
- visuals.selection.bg_fill = Color32::from_rgb(63, 90, 61);
- cc.egui_ctx.set_visuals(visuals);
+ configure_theme(&cc.egui_ctx);
+ app.brand_texture = Some(cc.egui_ctx.load_texture(
+ "tar-vault-sync-brand",
+ brand_image,
+ egui::TextureOptions::LINEAR,
+ ));
Ok(Box::new(app))
}),
)?;
@@ -1046,6 +1676,333 @@ pub fn run(root: PathBuf) -> Result<(), Box> {
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn drive_bindings_persist_reopen_and_reconnect_without_repointing() {
+ use crate::drive::{Provider, RemoteRef};
+ for provider in [Provider::OneDrive, Provider::GoogleDrive] {
+ let dir = tempfile::tempdir().unwrap();
+ let mut app = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ let connection = Connection {
+ id: "cloud".into(),
+ location: Location::Drive {
+ remote: RemoteRef {
+ provider,
+ credential_id: "synthetic-credential-reference".into(),
+ file_id: "synthetic-file".into(),
+ },
+ },
+ };
+ app.sources.add(connection.clone()).unwrap();
+ app.select_source("cloud").unwrap();
+ app.form = BindingForm {
+ id: "cloud-binding".into(),
+ source_kind: provider.store(),
+ connection: "cloud".into(),
+ entry: "sample".into(),
+ path: dir.path().join("target.txt").display().to_string(),
+ ..BindingForm::default()
+ };
+ let mut config = app.engine.config.clone();
+ config.bindings.push(app.form.binding().unwrap());
+ app.save_config(config).unwrap();
+ assert!(app.remove_source("cloud").is_err());
+ let mut replacement = connection;
+ if let Location::Drive { remote } = &mut replacement.location {
+ remote.file_id = "different-file".into();
+ }
+ assert!(app.sources.reconnect(replacement.clone()).is_err());
+ if let Location::Drive { remote } = &mut replacement.location {
+ remote.file_id = "synthetic-file".into();
+ remote.credential_id = "renewed-credential-reference".into();
+ }
+ app.sources.reconnect(replacement).unwrap();
+ drop(app);
+ let reopened = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ assert_eq!(
+ reopened.engine.config.bindings[0].source.store,
+ provider.store()
+ );
+ assert!(!reopened.sources.file("cloud").unwrap().is_unlocked());
+ }
+ }
+ /// Runs only in an isolated Linux virtual display. No real vault or account is opened.
+ #[cfg(target_os = "linux")]
+ #[test]
+ #[ignore = "requires Xvfb and TAR_VAULT_DOC_OUTPUT; renders synthetic documentation screens"]
+ fn render_documentation_screens() {
+ use winit::platform::x11::EventLoopBuilderExtX11;
+ let output = PathBuf::from(
+ std::env::var_os("TAR_VAULT_DOC_OUTPUT").expect("output directory required"),
+ );
+ assert!(output.is_absolute());
+ fs::create_dir_all(&output).unwrap();
+ let dir = tempfile::tempdir().unwrap();
+ let mut app = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ app.sources
+ .add(Connection {
+ id: "team-files".into(),
+ location: Location::FileSystem {
+ path: dir.path().join("team-vault.bin"),
+ },
+ })
+ .unwrap();
+ app.sources
+ .add(Connection {
+ id: "example-vault".into(),
+ location: Location::AzureKeyVault {
+ vault_name: "example-vault".into(),
+ },
+ })
+ .unwrap();
+ app.form = BindingForm {
+ id: "application-config".into(),
+ connection: "team-files".into(),
+ entry: "api-token".into(),
+ target_kind: TargetChoice::DotEnv,
+ path: dir.path().join("app.env").display().to_string(),
+ key: "API_TOKEN".into(),
+ ..BindingForm::default()
+ };
+ app.editing = true;
+ struct Capture {
+ app: DesktopApp,
+ output: PathBuf,
+ index: usize,
+ frames: usize,
+ started: std::time::Instant,
+ }
+ const PAGES: [(Page, &str); 6] = [
+ (Page::Overview, "overview"),
+ (Page::Sources, "sources"),
+ (Page::Vault, "vault"),
+ (Page::Bindings, "bindings"),
+ (Page::Events, "activity"),
+ (Page::Settings, "settings"),
+ ];
+ impl eframe::App for Capture {
+ fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
+ assert!(
+ self.started.elapsed().as_secs() < 60,
+ "documentation capture timed out"
+ );
+ for event in ctx.input(|i| i.events.clone()) {
+ if let egui::Event::Screenshot { image, .. } = event {
+ let mut file = std::io::BufWriter::new(
+ fs::File::create(
+ self.output.join(format!("{}.ppm", PAGES[self.index].1)),
+ )
+ .unwrap(),
+ );
+ write!(file, "P6\n{} {}\n255\n", image.size[0], image.size[1]).unwrap();
+ for pixel in &image.pixels {
+ file.write_all(&pixel.to_array()[..3]).unwrap();
+ }
+ self.index += 1;
+ self.frames = 0;
+ }
+ }
+ if self.index == PAGES.len() {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+ return;
+ }
+ if self.frames == 0 {
+ let height = if PAGES[self.index].0 == Page::Vault {
+ 1600.0
+ } else {
+ 960.0
+ };
+ ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
+ 1240.0, height,
+ )));
+ }
+ self.app.page = PAGES[self.index].0;
+ self.app.update(ctx, frame);
+ self.frames += 1;
+ if self.frames == 6 {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
+ }
+ ctx.request_repaint();
+ }
+ }
+ let options = eframe::NativeOptions {
+ viewport: egui::ViewportBuilder::default().with_inner_size([1240.0, 1500.0]),
+ event_loop_builder: Some(Box::new(|builder| {
+ builder.with_any_thread(true);
+ })),
+ ..Default::default()
+ };
+ eframe::run_native(
+ "TAR Vault Sync — documentation fixture",
+ options,
+ Box::new(move |cc| {
+ configure_theme(&cc.egui_ctx);
+ let icon = eframe::icon_data::from_png_bytes(BRAND_MARK).unwrap();
+ app.brand_texture = Some(cc.egui_ctx.load_texture(
+ "brand",
+ egui::ColorImage::from_rgba_unmultiplied(
+ [icon.width as usize, icon.height as usize],
+ &icon.rgba,
+ ),
+ egui::TextureOptions::LINEAR,
+ ));
+ Ok(Box::new(Capture {
+ app,
+ output,
+ index: 0,
+ frames: 0,
+ started: std::time::Instant::now(),
+ }))
+ }),
+ )
+ .unwrap();
+ }
+ #[test]
+ fn multiple_filesystem_sources_bind_and_reopen_without_repointing() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut app = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ app.sources
+ .add(Connection {
+ id: "external".into(),
+ location: Location::FileSystem {
+ path: dir.path().join("external.bin"),
+ },
+ })
+ .unwrap();
+ app.select_source("external").unwrap();
+ app.source.create("synthetic-test-passphrase").unwrap();
+ app.source
+ .put_entry(
+ "entry",
+ &SecretPayload::new(vec![31, 32, 33], SecretType::Binary),
+ )
+ .unwrap();
+ app.form = BindingForm {
+ id: "filesystem-binding".into(),
+ connection: "external".into(),
+ entry: "entry".into(),
+ secret_type: SecretType::Binary,
+ path: dir.path().join("target.bin").display().to_string(),
+ interval: "86400".into(),
+ policy: MissingTargetPolicy::Recreate,
+ ..BindingForm::default()
+ };
+ app.save_binding();
+ assert!(!app.is_error);
+ let binding = &app.engine.config.bindings[0];
+ assert_eq!(binding.source.connection, "external");
+ assert_eq!(
+ BindingForm::from_binding(binding).binding().unwrap(),
+ *binding
+ );
+ assert!(app.remove_source("external").is_err());
+ let result = app.runtime.block_on(app.engine.sync("filesystem-binding"));
+ assert!(matches!(
+ result,
+ SyncOutcome::Applied | SyncOutcome::Unchanged
+ ));
+ app.passphrase.push_str("synthetic-discarded-input");
+ app.secret_text.push_str("synthetic-discarded-input");
+ app.select_source("local").unwrap();
+ assert!(app.passphrase.is_empty() && app.secret_text.is_empty());
+ assert!(!app.source.exists());
+ drop(app);
+ let mut reopened = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ assert_eq!(
+ reopened.engine.config.bindings[0].source.connection,
+ "external"
+ );
+ assert!(!reopened.sources.file("external").unwrap().is_unlocked());
+ reopened.delete_binding("filesystem-binding");
+ assert!(!reopened.is_error);
+ reopened.remove_source("external").unwrap();
+ assert!(dir.path().join("external.bin").exists());
+ }
+ #[test]
+ fn azure_binding_can_be_saved_and_edited_without_a_local_vault() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut app = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ app.form = BindingForm {
+ id: "azure-binding".into(),
+ source_kind: StoreKind::Azure,
+ connection: "test-vault".into(),
+ entry: "test-secret".into(),
+ path: dir.path().join("authorized.txt").display().to_string(),
+ interval: "86400".into(),
+ ..BindingForm::default()
+ };
+ app.save_binding();
+ assert!(!app.is_error);
+ assert!(!app.source.exists());
+ let binding = &app.engine.config.bindings[0];
+ assert_eq!(binding.source.store, StoreKind::Azure);
+ assert_eq!(binding.source.connection, "test-vault");
+ let edited = BindingForm::from_binding(binding).binding().unwrap();
+ assert_eq!(&edited, binding);
+ let mut invalid = app.engine.config.clone();
+ invalid.bindings[0].secret_type = SecretType::Binary;
+ assert!(app.save_config(invalid).is_err());
+ let mut invalid = app.engine.config.clone();
+ invalid.bindings[0].source.connection = "test.vault".into();
+ assert!(app.save_config(invalid).is_err());
+ drop(app);
+ let reopened = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ assert_eq!(reopened.engine.config.bindings.len(), 1);
+ assert_eq!(
+ reopened.engine.config.bindings[0].source.store,
+ StoreKind::Azure
+ );
+ }
+
+ #[test]
+ fn embedded_brand_mark_is_valid_square_rgba_with_transparency() {
+ let icon = eframe::icon_data::from_png_bytes(BRAND_MARK).unwrap();
+ assert_eq!(icon.width, icon.height);
+ assert!(icon.width >= 256);
+ assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize);
+ assert!(icon.rgba.chunks_exact(4).any(|pixel| pixel[3] == 0));
+ assert!(icon.rgba.chunks_exact(4).any(|pixel| pixel[3] == 255));
+ let image = egui::ColorImage::from_rgba_unmultiplied(
+ [icon.width as usize, icon.height as usize],
+ &icon.rgba,
+ );
+ let ctx = egui::Context::default();
+ let texture = ctx.load_texture("brand-test", image, egui::TextureOptions::LINEAR);
+ assert_eq!(texture.size(), [icon.width as usize, icon.height as usize]);
+ }
+
+ #[test]
+ fn browser_import_action_requires_fresh_consent_and_redacts_status() {
+ let dir = tempfile::tempdir().unwrap();
+ let csv = dir.path().join("synthetic.csv");
+ fs::write(
+ &csv,
+ b"url,username,password\nhttps://example.invalid,user,synthetic-probe",
+ )
+ .unwrap();
+ let mut app = DesktopApp::new(dir.path().to_path_buf()).unwrap();
+ app.source.create("test-only-passphrase").unwrap();
+ app.import_path = csv.display().to_string();
+ app.import_prefix = "browser".into();
+ app.import_browser_csv();
+ assert!(app.is_error);
+ assert!(app.source.entries().unwrap().is_empty());
+ app.import_consent = true;
+ app.import_browser_csv();
+ assert!(!app.is_error);
+ assert!(!app.import_consent);
+ assert!(app.import_path.is_empty());
+ assert_eq!(app.source.entries().unwrap().len(), 1);
+ assert!(!app.message.contains("synthetic-probe"));
+ assert!(!app.message.contains("example.invalid"));
+ app.import_path = csv.display().to_string();
+ app.import_prefix = "browser".into();
+ app.import_consent = true;
+ app.import_browser_csv();
+ assert!(app.is_error);
+ assert!(!app.import_consent);
+ assert_eq!(app.source.entries().unwrap().len(), 1);
+ }
+
#[test]
fn binding_form_emits_typed_targets_without_secret_values() {
let mut form = BindingForm {
diff --git a/src/drive.rs b/src/drive.rs
new file mode 100644
index 0000000..793a8c8
--- /dev/null
+++ b/src/drive.rs
@@ -0,0 +1,824 @@
+//! Encrypted Drive transport. Credentials never enter source configuration.
+use crate::{
+ cloud_auth,
+ vault::{CipherStore, VaultError},
+};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{
+ io::Read,
+ sync::Mutex,
+ time::{Duration, Instant},
+};
+use url::Url;
+use zeroize::Zeroizing;
+
+pub(crate) const MAX_CIPHERTEXT: usize = 16 * 1024 * 1024;
+#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum Provider {
+ OneDrive,
+ GoogleDrive,
+}
+impl Provider {
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ Self::OneDrive => "OneDrive",
+ Self::GoogleDrive => "Google Drive",
+ }
+ }
+ pub(crate) fn store(self) -> crate::core::StoreKind {
+ match self {
+ Self::OneDrive => crate::core::StoreKind::OneDrive,
+ Self::GoogleDrive => crate::core::StoreKind::GoogleDrive,
+ }
+ }
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum DriveError {
+ Authorization,
+ CredentialStore,
+ InvalidInput,
+ Network,
+ Conflict,
+ InvalidResponse,
+}
+impl std::fmt::Display for DriveError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Authorization => "Drive authorization failed or expired. Reconnect the account.",
+ Self::CredentialStore => "The operating-system credential store is unavailable. No plaintext fallback is used.",
+ Self::InvalidInput => "Invalid Drive selection or configuration.",
+ Self::Network => "Drive request failed. Check connectivity and retry; no automatic overwrite was attempted.",
+ Self::Conflict => "The remote vault changed. Reload it before retrying your edit.",
+ Self::InvalidResponse => "Drive returned an unsupported or invalid response.",
+ })
+ }
+}
+impl From for VaultError {
+ fn from(error: DriveError) -> Self {
+ match error {
+ DriveError::Conflict => Self::VersionConflict,
+ DriveError::Authorization | DriveError::CredentialStore => Self::Locked,
+ DriveError::InvalidInput | DriveError::InvalidResponse => Self::Corrupt,
+ DriveError::Network => Self::Storage,
+ }
+ }
+}
+pub(crate) fn valid_id(id: &str) -> bool {
+ !id.is_empty()
+ && id.len() <= 256
+ && id
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b"_-!".contains(&b))
+}
+pub(crate) fn bounded(reader: impl Read, limit: usize) -> Result>, DriveError> {
+ let mut result = Zeroizing::new(Vec::new());
+ reader
+ .take(limit as u64 + 1)
+ .read_to_end(&mut result)
+ .map_err(|_| DriveError::Network)?;
+ if result.len() > limit {
+ return Err(DriveError::InvalidResponse);
+ }
+ Ok(result)
+}
+fn parse(bytes: &[u8]) -> Result {
+ serde_json::from_slice(bytes).map_err(|_| DriveError::InvalidResponse)
+}
+fn string(value: &Value, key: &str) -> Result {
+ value[key]
+ .as_str()
+ .filter(|v| !v.is_empty())
+ .map(str::to_owned)
+ .ok_or(DriveError::InvalidResponse)
+}
+fn checked_etag(etag: String) -> Result {
+ if etag.len() > 1024
+ || etag.starts_with("W/")
+ || !etag.starts_with('"')
+ || !etag.ends_with('"')
+ || etag.bytes().any(|b| b.is_ascii_control())
+ {
+ return Err(DriveError::InvalidResponse);
+ }
+ Ok(etag)
+}
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct RemoteRef {
+ pub provider: Provider,
+ pub credential_id: String,
+ pub file_id: String,
+}
+impl RemoteRef {
+ pub(crate) fn validate(&self) -> bool {
+ valid_id(&self.credential_id) && valid_id(&self.file_id)
+ }
+}
+pub(crate) struct Item {
+ pub id: String,
+ pub name: String,
+ pub folder: bool,
+}
+impl Item {
+ fn parse(provider: Provider, value: &Value) -> Result {
+ let id = string(value, "id")?;
+ if !valid_id(&id) {
+ return Err(DriveError::InvalidResponse);
+ }
+ let name = string(
+ value,
+ if provider == Provider::OneDrive {
+ "name"
+ } else {
+ "title"
+ },
+ )?;
+ if name.len() > 1024 {
+ return Err(DriveError::InvalidResponse);
+ }
+ Ok(Self {
+ id,
+ name,
+ folder: if provider == Provider::OneDrive {
+ value.get("folder").is_some()
+ } else {
+ value["mimeType"] == "application/vnd.google-apps.folder"
+ },
+ })
+ }
+}
+pub(crate) struct Api {
+ pub provider: Provider,
+ pub credential_id: String,
+ token: Mutex, Instant)>>,
+ #[cfg(test)]
+ script: Option>>,
+}
+impl Api {
+ pub(crate) fn new(provider: Provider, credential_id: String) -> Self {
+ Self {
+ provider,
+ credential_id,
+ token: Mutex::new(None),
+ #[cfg(test)]
+ script: None,
+ }
+ }
+ fn request(
+ &self,
+ method: &str,
+ url: &str,
+ body: &[u8],
+ content_type: &str,
+ condition: Option<&str>,
+ limit: usize,
+ ) -> Result>, DriveError> {
+ let expected_host = match self.provider {
+ Provider::OneDrive => "graph.microsoft.com",
+ Provider::GoogleDrive => "www.googleapis.com",
+ };
+ let parsed = Url::parse(url).map_err(|_| DriveError::InvalidInput)?;
+ if parsed.scheme() != "https"
+ || parsed.host_str() != Some(expected_host)
+ || parsed.port().is_some()
+ || !parsed.username().is_empty()
+ || parsed.password().is_some()
+ {
+ return Err(DriveError::InvalidInput);
+ }
+ let mut token = self.token.lock().map_err(|_| DriveError::Authorization)?;
+ #[cfg(test)]
+ if let Some(script) = &self.script {
+ let step = script
+ .lock()
+ .unwrap()
+ .pop_front()
+ .expect("unexpected request");
+ assert_eq!(method, step.method);
+ assert!(url.contains(step.url_part));
+ assert_eq!(condition, step.condition);
+ if method == "PUT" {
+ assert!(body.starts_with(b"TVAULT02"));
+ }
+ return step.result.map(Zeroizing::new);
+ }
+ if token
+ .as_ref()
+ .is_none_or(|(_, at)| at.elapsed() > Duration::from_secs(300))
+ {
+ *token = Some((
+ cloud_auth::access_token(self.provider, &self.credential_id)?,
+ Instant::now(),
+ ));
+ }
+ let authorization = Zeroizing::new(format!(
+ "Bearer {}",
+ token.as_ref().ok_or(DriveError::Authorization)?.0.as_str()
+ ));
+ let agent = cloud_auth::agent();
+ macro_rules! headers {
+ ($request:expr) => {{
+ let mut request = $request
+ .header("Authorization", authorization.as_str())
+ .header("Content-Type", content_type);
+ if let Some(etag) = condition {
+ request = request.header("If-Match", etag);
+ }
+ request
+ }};
+ }
+ let result = match method {
+ "GET" => headers!(agent.get(url)).call(),
+ "PUT" => headers!(agent.put(url)).send(body),
+ "POST" => headers!(agent.post(url)).send(body),
+ #[cfg(test)]
+ "DELETE" => headers!(agent.delete(url)).call(),
+ _ => return Err(DriveError::InvalidInput),
+ };
+ let mut response = result.map_err(|error| match error {
+ ureq::Error::StatusCode(401 | 403) => {
+ *token = None;
+ DriveError::Authorization
+ }
+ ureq::Error::StatusCode(409 | 412) => DriveError::Conflict,
+ _ => DriveError::Network,
+ })?;
+ if !response.status().is_success() {
+ return Err(DriveError::Network);
+ }
+ bounded(response.body_mut().as_reader(), limit)
+ }
+ fn metadata_url(&self, id: &str) -> Result {
+ if !valid_id(id) {
+ return Err(DriveError::InvalidInput);
+ }
+ Ok(match self.provider {
+ Provider::OneDrive if id == "root" => "https://graph.microsoft.com/v1.0/me/drive/root".into(),
+ Provider::OneDrive => format!("https://graph.microsoft.com/v1.0/me/drive/items/{id}"),
+ Provider::GoogleDrive => format!("https://www.googleapis.com/drive/v2/files/{id}?fields=id,title,mimeType,etag,labels,fileSize,headRevisionId"),
+ })
+ }
+ fn metadata(&self, id: &str) -> Result {
+ parse(&self.request(
+ "GET",
+ &self.metadata_url(id)?,
+ &[],
+ "application/json",
+ None,
+ 256 * 1024,
+ )?)
+ }
+ pub(crate) fn item(&self, id: &str) -> Result- {
+ Item::parse(self.provider, &self.metadata(id)?)
+ }
+ pub(crate) fn children(&self, folder: &str) -> Result
, DriveError> {
+ if !valid_id(folder) {
+ return Err(DriveError::InvalidInput);
+ }
+ let mut url = match self.provider {
+ Provider::OneDrive => format!("https://graph.microsoft.com/v1.0/me/drive/items/{folder}/children?$top=200&$select=id,name,folder"),
+ Provider::GoogleDrive => {
+ let mut url = Url::parse("https://www.googleapis.com/drive/v2/files").map_err(|_| DriveError::InvalidInput)?;
+ url.query_pairs_mut().append_pair("q", &format!("'{folder}' in parents and trashed = false"))
+ .append_pair("maxResults", "1000").append_pair("fields", "items(id,title,mimeType),nextPageToken");
+ url.to_string()
+ }
+ };
+ let first = url.clone();
+ let mut items = Vec::new();
+ for _ in 0..100 {
+ let value =
+ parse(&self.request("GET", &url, &[], "application/json", None, 1024 * 1024)?)?;
+ let key = if self.provider == Provider::OneDrive {
+ "value"
+ } else {
+ "items"
+ };
+ for item in value[key].as_array().ok_or(DriveError::InvalidResponse)? {
+ items.push(Item::parse(self.provider, item)?);
+ }
+ if items.len() > 10_000 {
+ return Err(DriveError::InvalidResponse);
+ }
+ if self.provider == Provider::OneDrive {
+ match value["@odata.nextLink"].as_str() {
+ Some(next) => url = next.into(),
+ None => return Ok(items),
+ }
+ } else {
+ match value["nextPageToken"].as_str() {
+ Some(next) => {
+ let mut next_url =
+ Url::parse(&first).map_err(|_| DriveError::InvalidResponse)?;
+ next_url.query_pairs_mut().append_pair("pageToken", next);
+ url = next_url.into();
+ }
+ None => return Ok(items),
+ }
+ }
+ }
+ Err(DriveError::InvalidResponse)
+ }
+ fn etag(&self, metadata: &Value) -> Result {
+ if metadata.get("deleted").is_some() || metadata["labels"]["trashed"] == true {
+ return Err(DriveError::InvalidResponse);
+ }
+ let item = Item::parse(self.provider, metadata)?;
+ if item.folder {
+ return Err(DriveError::InvalidInput);
+ }
+ checked_etag(string(
+ metadata,
+ if self.provider == Provider::OneDrive {
+ "eTag"
+ } else {
+ "etag"
+ },
+ )?)
+ }
+ pub(crate) fn create(
+ &self,
+ folder: &str,
+ name: &str,
+ ciphertext: &[u8],
+ ) -> Result {
+ if !valid_id(folder)
+ || name.is_empty()
+ || name.len() > 128
+ || !name.ends_with(".tarvault")
+ || !name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
+ || ciphertext.len() > MAX_CIPHERTEXT
+ || !ciphertext.starts_with(b"TVAULT02")
+ {
+ return Err(DriveError::InvalidInput);
+ }
+ if !self.item(folder)?.folder {
+ return Err(DriveError::InvalidInput);
+ }
+ let response = match self.provider {
+ Provider::OneDrive => self.request("PUT", &format!("https://graph.microsoft.com/v1.0/me/drive/items/{folder}:/{name}:/content?@microsoft.graph.conflictBehavior=fail"), ciphertext, "application/octet-stream", None, 256 * 1024)?,
+ Provider::GoogleDrive => {
+ let boundary = cloud_auth::random_id();
+ let metadata = json!({"title": name, "mimeType": "application/octet-stream", "parents": [{"id": folder}]});
+ let mut body = format!("--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata}\r\n--{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n").into_bytes();
+ body.extend_from_slice(ciphertext);
+ body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
+ self.request("POST", "https://www.googleapis.com/upload/drive/v2/files?uploadType=multipart", &body, &format!("multipart/related; boundary={boundary}"), None, 256 * 1024)?
+ }
+ };
+ let file_id = string(&parse(&response)?, "id")?;
+ if !valid_id(&file_id) {
+ return Err(DriveError::InvalidResponse);
+ }
+ Ok(RemoteRef {
+ provider: self.provider,
+ credential_id: self.credential_id.clone(),
+ file_id,
+ })
+ }
+}
+
+// Two concrete backends (Drive and local filesystem) share the vault cryptography.
+pub(crate) struct RemoteFile {
+ api: Api,
+ id: String,
+ state: Mutex)>>,
+}
+impl RemoteFile {
+ pub(crate) fn new(reference: &RemoteRef) -> Result {
+ if !reference.validate() {
+ return Err(DriveError::InvalidInput);
+ }
+ Ok(Self {
+ api: Api::new(reference.provider, reference.credential_id.clone()),
+ id: reference.file_id.clone(),
+ state: Mutex::new(None),
+ })
+ }
+ fn read_remote(&self) -> Result, DriveError> {
+ let mut state = self.state.lock().map_err(|_| DriveError::Network)?;
+ let metadata = self.api.metadata(&self.id)?;
+ let etag = self.api.etag(&metadata)?;
+ if let Some((cached, bytes)) = &*state {
+ if *cached == etag {
+ return Ok(bytes.clone());
+ }
+ }
+ let bytes = match self.api.provider {
+ Provider::GoogleDrive => {
+ // Metadata and media representations have different ETags. Pin the
+ // payload to the revision observed before retrieval instead.
+ let revision = string(&metadata, "headRevisionId")?;
+ if !valid_id(&revision) {
+ return Err(DriveError::InvalidResponse);
+ }
+ self.api.request(
+ "GET",
+ &format!(
+ "https://www.googleapis.com/drive/v2/files/{}?alt=media&revisionId={revision}",
+ self.id
+ ),
+ &[],
+ "application/octet-stream",
+ None,
+ MAX_CIPHERTEXT,
+ )?
+ }
+ Provider::OneDrive => {
+ let download = string(&metadata, "@microsoft.graph.downloadUrl")?;
+ check_download_url(&download)?;
+ // Preauthenticated URL: never forward the Graph bearer token to a CDN.
+ let mut response = cloud_auth::agent()
+ .get(&download)
+ .call()
+ .map_err(|_| DriveError::Network)?;
+ if !response.status().is_success() {
+ return Err(DriveError::Network);
+ }
+ bounded(response.body_mut().as_reader(), MAX_CIPHERTEXT)?
+ }
+ };
+ if !bytes.starts_with(b"TVAULT02") {
+ return Err(DriveError::InvalidResponse);
+ }
+ if self.api.etag(&self.api.metadata(&self.id)?)? != etag {
+ return Err(DriveError::Conflict);
+ }
+ // Only ciphertext is cached; LocalVault authenticates it before using entries.
+ *state = Some((etag, bytes.to_vec()));
+ Ok(bytes.to_vec())
+ }
+ fn write_remote(&self, bytes: &[u8]) -> Result<(), DriveError> {
+ if bytes.len() > MAX_CIPHERTEXT || !bytes.starts_with(b"TVAULT02") {
+ return Err(DriveError::InvalidInput);
+ }
+ let mut state = self.state.lock().map_err(|_| DriveError::Network)?;
+ let etag = &state.as_ref().ok_or(DriveError::Conflict)?.0;
+ let url = match self.api.provider {
+ Provider::OneDrive => format!(
+ "https://graph.microsoft.com/v1.0/me/drive/items/{}/content",
+ self.id
+ ),
+ Provider::GoogleDrive => format!(
+ "https://www.googleapis.com/upload/drive/v2/files/{}?uploadType=media",
+ self.id
+ ),
+ };
+ self.api.request(
+ "PUT",
+ &url,
+ bytes,
+ "application/octet-stream",
+ Some(etag),
+ 256 * 1024,
+ )?;
+ // Force the next read to obtain the server's committed revision, never guess it.
+ *state = None;
+ Ok(())
+ }
+}
+impl CipherStore for RemoteFile {
+ fn disconnect(&self) -> Result<(), VaultError> {
+ *self.api.token.lock().map_err(|_| VaultError::Locked)? = None;
+ *self.state.lock().map_err(|_| VaultError::Locked)? = None;
+ cloud_auth::disconnect(&self.api.credential_id).map_err(Into::into)
+ }
+ fn read(&self) -> Result, VaultError> {
+ self.read_remote().map_err(Into::into)
+ }
+ fn replace(&self, bytes: &[u8]) -> Result<(), VaultError> {
+ self.write_remote(bytes).map_err(Into::into)
+ }
+}
+fn check_download_url(value: &str) -> Result<(), DriveError> {
+ let url = Url::parse(value).map_err(|_| DriveError::InvalidResponse)?;
+ let host = url.host_str().ok_or(DriveError::InvalidResponse)?;
+ if url.scheme() != "https"
+ || url.port().is_some()
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || ![".1drv.com", ".sharepoint.com", ".storage.live.com"]
+ .iter()
+ .any(|suffix| host.ends_with(suffix))
+ {
+ return Err(DriveError::InvalidResponse);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+struct ScriptStep {
+ method: &'static str,
+ url_part: &'static str,
+ condition: Option<&'static str>,
+ result: Result, DriveError>,
+}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ #[ignore = "interactive Microsoft sign-in; creates and trashes only an isolated synthetic vault"]
+ fn live_onedrive_encrypted_roundtrip_and_conflict() {
+ live_roundtrip(Provider::OneDrive, Zeroizing::new(String::new()));
+ }
+ #[test]
+ #[ignore = "interactive Google Picker; requires TAR_VAULT_GOOGLE_CLIENT_FILE and a selected test folder"]
+ fn live_google_encrypted_roundtrip_and_conflict() {
+ live_roundtrip(
+ Provider::GoogleDrive,
+ cloud_auth::tests::test_google_client_secret().unwrap(),
+ );
+ }
+ fn live_roundtrip(provider: Provider, client_secret: Zeroizing) {
+ use crate::core::{SecretPayload, SecretType, SourceConnection, SourceRef, StoreKind};
+ use crate::vault::{LocalVault, VaultSource};
+ use std::sync::Arc;
+ eprintln!("Opening provider OAuth consent for the TAR Vault Sync Desktop app.");
+ let login = cloud_auth::login(provider, client_secret).unwrap();
+ let api = Api::new(login.provider, login.credential_id.clone());
+ let dir = tempfile::tempdir().unwrap();
+ let seed_path = dir.path().join("synthetic.tarvault");
+ let passphrase = Zeroizing::new(cloud_auth::random_id());
+ let mut seed = LocalVault::create(&seed_path, &passphrase).unwrap();
+ let ciphertext = std::fs::read(&seed_path).unwrap();
+ let filename = format!(
+ "tarvaultsync-test-{}.tarvault",
+ &cloud_auth::random_id()[..12]
+ );
+ let created = (|| {
+ let selected = if provider == Provider::GoogleDrive {
+ login.picked.as_deref().ok_or(DriveError::InvalidInput)?
+ } else {
+ "root"
+ };
+ let root = api.item(selected)?;
+ api.create(&root.id, &filename, &ciphertext)
+ })();
+ let reference = match created {
+ Ok(reference) => reference,
+ Err(error) => {
+ cloud_auth::disconnect(&login.credential_id).unwrap();
+ panic!("Live vault creation failed: {error}");
+ }
+ };
+ let result = (|| -> Result<(), String> {
+ let first = RemoteFile::new(&reference).map_err(|e| e.to_string())?;
+ let second = RemoteFile::new(&reference).map_err(|e| e.to_string())?;
+ first
+ .read_remote()
+ .map_err(|e| format!("initial download: {e}"))?;
+ second
+ .read_remote()
+ .map_err(|e| format!("second download: {e}"))?;
+ seed.put(
+ "sample",
+ &SecretPayload::new(vec![1, 2, 3], SecretType::Binary),
+ )
+ .map_err(|e| e.to_string())?;
+ let updated = std::fs::read(&seed_path).map_err(|_| "fixture read failed")?;
+ second
+ .write_remote(&updated)
+ .map_err(|e| format!("first conditional update: {e}"))?;
+ if first.write_remote(&ciphertext) != Err(DriveError::Conflict) {
+ return Err("Provider did not reject stale If-Match".into());
+ }
+ eprintln!("PASS: provider rejects a stale conditional write.");
+ let remote = Arc::new(RemoteFile::new(&reference).map_err(|e| e.to_string())?);
+ let source =
+ VaultSource::remote(dir.path().join("never-cached.bin"), "live".into(), remote)
+ .map_err(|e| e.to_string())?;
+ source.unlock(&passphrase).map_err(|e| e.to_string())?;
+ let entry = SourceRef {
+ store: StoreKind::LocalVault,
+ connection: "live".into(),
+ entry: "sample".into(),
+ };
+ let old = source.get_version(&entry).map_err(|e| e.to_string())?;
+ if source
+ .get_value(&entry, &old)
+ .map_err(|e| e.to_string())?
+ .as_bytes()
+ != [1, 2, 3]
+ {
+ return Err("Payload mismatch".into());
+ }
+ source
+ .put_entry(
+ "sample",
+ &SecretPayload::new(vec![4, 5, 6], SecretType::Binary),
+ )
+ .map_err(|e| e.to_string())?;
+ let current = source.get_version(&entry).map_err(|e| e.to_string())?;
+ if current == old
+ || source
+ .get_value(&entry, ¤t)
+ .map_err(|e| e.to_string())?
+ .as_bytes()
+ != [4, 5, 6]
+ {
+ return Err("Rotation mismatch".into());
+ }
+ source.lock();
+ source.unlock(&passphrase).map_err(|e| e.to_string())?;
+ if source.get_version(&entry).map_err(|e| e.to_string())? != current {
+ return Err("Reopen mismatch".into());
+ }
+ if dir.path().join("never-cached.bin").exists() {
+ return Err("Unexpected local cache".into());
+ }
+ eprintln!("PASS: encrypted upload, authenticated download, rotation, lock and reopen.");
+ Ok(())
+ })();
+ let cleanup = if provider == Provider::GoogleDrive {
+ api.request(
+ "POST",
+ &format!(
+ "https://www.googleapis.com/drive/v2/files/{}/trash",
+ reference.file_id
+ ),
+ &[],
+ "application/json",
+ None,
+ 256 * 1024,
+ )
+ } else {
+ api.request(
+ "DELETE",
+ &format!(
+ "https://graph.microsoft.com/v1.0/me/drive/items/{}",
+ reference.file_id
+ ),
+ &[],
+ "application/json",
+ None,
+ 1024,
+ )
+ };
+ let disconnected = cloud_auth::disconnect(&login.credential_id);
+ if cleanup.is_err() {
+ eprintln!("Cleanup required for synthetic file: {filename}");
+ }
+ cleanup.unwrap();
+ disconnected.unwrap();
+ eprintln!(
+ "PASS: synthetic vault moved to provider trash; temporary OAuth credential removed."
+ );
+ result.unwrap();
+ }
+ #[test]
+ fn google_metadata_first_skips_unchanged_download_and_conditionally_writes() {
+ let metadata = serde_json::to_vec(&json!({"id":"file", "title":"vault.tarvault", "mimeType":"application/octet-stream", "etag":"\"v1\"", "headRevisionId":"revision1"})).unwrap();
+ let step = |method, url_part, condition, result| ScriptStep {
+ method,
+ url_part,
+ condition,
+ result,
+ };
+ let mut api = Api::new(Provider::GoogleDrive, "test".into());
+ api.script = Some(Mutex::new(
+ [
+ step("GET", "fields=", None, Ok(metadata.clone())),
+ step(
+ "GET",
+ "alt=media&revisionId=revision1",
+ None,
+ Ok(b"TVAULT02encrypted-fixture".to_vec()),
+ ),
+ step("GET", "fields=", None, Ok(metadata.clone())),
+ step("GET", "fields=", None, Ok(metadata)),
+ step(
+ "PUT",
+ "uploadType=media",
+ Some("\"v1\""),
+ Err(DriveError::Conflict),
+ ),
+ ]
+ .into(),
+ ));
+ let remote = RemoteFile {
+ api,
+ id: "file".into(),
+ state: Mutex::new(None),
+ };
+ assert!(remote.read_remote().is_ok());
+ assert!(remote.read_remote().is_ok());
+ assert_eq!(
+ remote.write_remote(b"TVAULT02new-ciphertext"),
+ Err(DriveError::Conflict)
+ );
+ assert!(remote
+ .api
+ .script
+ .as_ref()
+ .unwrap()
+ .lock()
+ .unwrap()
+ .is_empty());
+ assert_eq!(remote.state.lock().unwrap().as_ref().unwrap().0, "\"v1\"");
+ }
+ #[test]
+ fn google_download_requires_a_valid_revision_and_rejects_concurrent_change() {
+ for revision in [None, Some("../invalid"), Some("revision1")] {
+ let mut metadata = json!({"id":"file", "title":"vault.tarvault", "mimeType":"application/octet-stream", "etag":"\"v1\""});
+ if let Some(revision) = revision {
+ metadata["headRevisionId"] = json!(revision);
+ }
+ let mut steps = vec![ScriptStep {
+ method: "GET",
+ url_part: "fields=",
+ condition: None,
+ result: Ok(serde_json::to_vec(&metadata).unwrap()),
+ }];
+ if revision == Some("revision1") {
+ steps.push(ScriptStep {
+ method: "GET",
+ url_part: "alt=media&revisionId=revision1",
+ condition: None,
+ result: Ok(b"TVAULT02encrypted-fixture".to_vec()),
+ });
+ metadata["etag"] = json!("\"v2\"");
+ steps.push(ScriptStep {
+ method: "GET",
+ url_part: "fields=",
+ condition: None,
+ result: Ok(serde_json::to_vec(&metadata).unwrap()),
+ });
+ }
+ let mut api = Api::new(Provider::GoogleDrive, "test".into());
+ api.script = Some(Mutex::new(steps.into()));
+ let remote = RemoteFile {
+ api,
+ id: "file".into(),
+ state: Mutex::new(None),
+ };
+ let expected = if revision == Some("revision1") {
+ DriveError::Conflict
+ } else {
+ DriveError::InvalidResponse
+ };
+ assert_eq!(remote.read_remote(), Err(expected));
+ assert!(remote.state.lock().unwrap().is_none());
+ assert!(remote
+ .api
+ .script
+ .as_ref()
+ .unwrap()
+ .lock()
+ .unwrap()
+ .is_empty());
+ }
+ }
+ #[test]
+ fn onedrive_write_carries_the_observed_etag_and_never_retries_conflict() {
+ let mut api = Api::new(Provider::OneDrive, "test".into());
+ api.script = Some(Mutex::new(
+ [ScriptStep {
+ method: "PUT",
+ url_part: "/items/file/content",
+ condition: Some("\"v1\""),
+ result: Err(DriveError::Conflict),
+ }]
+ .into(),
+ ));
+ let remote = RemoteFile {
+ api,
+ id: "file".into(),
+ state: Mutex::new(Some(("\"v1\"".into(), b"TVAULT02old".to_vec()))),
+ };
+ assert_eq!(
+ remote.write_remote(b"TVAULT02new"),
+ Err(DriveError::Conflict)
+ );
+ assert!(remote
+ .api
+ .script
+ .as_ref()
+ .unwrap()
+ .lock()
+ .unwrap()
+ .is_empty());
+ }
+ #[test]
+ fn rejects_injected_references_and_download_urls() {
+ for id in ["", "../x", "a/b", "x?q=1", "a%2fb"] {
+ assert!(!valid_id(id));
+ }
+ for url in [
+ "http://x.1drv.com/a",
+ "https://127.0.0.1/a",
+ "https://evil.test/a",
+ "https://x.1drv.com.evil.test/a",
+ "https://user@x.1drv.com/a",
+ ] {
+ assert!(check_download_url(url).is_err());
+ }
+ assert!(check_download_url("https://x.1drv.com/a?token=opaque").is_ok());
+ assert!(checked_etag("W/\"weak\"".into()).is_err());
+ assert!(checked_etag("\"revision\"".into()).is_ok());
+ assert!(bounded(&b"oversized"[..], 2).is_err());
+ }
+}
diff --git a/src/drive_ui.rs b/src/drive_ui.rs
new file mode 100644
index 0000000..6a3294a
--- /dev/null
+++ b/src/drive_ui.rs
@@ -0,0 +1,193 @@
+//! Native setup flow; only the provider's OAuth/Picker opens a browser.
+use crate::{
+ cloud_auth,
+ drive::{Api, DriveError, Item, Provider, RemoteRef},
+ sources::{Connection, Location},
+ vault::LocalVault,
+};
+use eframe::egui;
+use std::{
+ sync::{mpsc, Arc},
+ time::Duration,
+};
+use zeroize::{Zeroize, Zeroizing};
+
+enum ResultEvent {
+ Connected(Arc, Item, Vec- ),
+ Listed(String, Vec
- ),
+ Selected(RemoteRef),
+}
+pub(crate) struct DriveForm {
+ provider: Provider,
+ client_secret: Zeroizing
,
+ passphrase: Zeroizing,
+ connection_id: String,
+ filename: String,
+ api: Option>,
+ folder: String,
+ items: Vec- ,
+ pending: Option
>>,
+ message: String,
+ ready: Option,
+}
+impl Default for DriveForm {
+ fn default() -> Self {
+ Self {
+ provider: Provider::OneDrive,
+ client_secret: Zeroizing::new(String::new()),
+ passphrase: Zeroizing::new(String::new()),
+ connection_id: String::new(),
+ filename: "vault.tarvault".into(),
+ api: None,
+ folder: String::new(),
+ items: Vec::new(),
+ pending: None,
+ message: String::new(),
+ ready: None,
+ }
+ }
+}
+impl DriveForm {
+ fn start(&mut self, action: impl FnOnce() -> Result + Send + 'static) {
+ let (sender, receiver) = mpsc::sync_channel(1);
+ self.pending = Some(receiver);
+ self.message = "Working… Browser authorization expires after three minutes.".into();
+ std::thread::spawn(move || {
+ let _ = sender.send(action());
+ });
+ }
+ pub(crate) fn show(&mut self, ui: &mut egui::Ui) -> Option {
+ if let Some(receiver) = &self.pending {
+ match receiver.try_recv() {
+ Ok(result) => {
+ self.pending = None;
+ match result {
+ Ok(ResultEvent::Connected(api, item, items)) => {
+ if item.folder {
+ self.folder = item.id;
+ self.items = items;
+ } else {
+ self.ready = Some(RemoteRef {
+ provider: api.provider,
+ credential_id: api.credential_id.clone(),
+ file_id: item.id,
+ });
+ }
+ self.api = Some(api);
+ self.message = "Account connected. Select an encrypted vault or create one in the selected folder.".into();
+ }
+ Ok(ResultEvent::Listed(folder, items)) => {
+ self.folder = folder;
+ self.items = items;
+ self.message.clear();
+ }
+ Ok(ResultEvent::Selected(remote)) => {
+ self.ready = Some(remote);
+ self.passphrase.zeroize();
+ self.message = "Vault selected. Add the connection, then unlock it in Manage vault.".into();
+ }
+ Err(error) => self.message = error.to_string(),
+ }
+ }
+ Err(mpsc::TryRecvError::Disconnected) => {
+ self.pending = None;
+ self.message = "Drive operation stopped unexpectedly.".into();
+ }
+ Err(mpsc::TryRecvError::Empty) => {
+ ui.ctx().request_repaint_after(Duration::from_millis(100));
+ }
+ }
+ }
+ ui.separator();
+ ui.heading("Connect a cloud vault");
+ ui.label("Cloud vaults stay encrypted. Tokens use the OS credential store. No plaintext fallback.");
+ ui.label(&self.message);
+ let mut result = None;
+ ui.add_enabled_ui(self.pending.is_none(), |ui| {
+ ui.label("Connection ID"); ui.text_edit_singleline(&mut self.connection_id);
+ if self.api.is_none() {
+ ui.horizontal(|ui| {
+ ui.selectable_value(&mut self.provider, Provider::OneDrive, "OneDrive");
+ ui.selectable_value(&mut self.provider, Provider::GoogleDrive, "Google Drive");
+ });
+ if self.provider == Provider::GoogleDrive {
+ ui.label("Desktop OAuth client secret (stored only in OS credentials)");
+ ui.add(egui::TextEdit::singleline(&mut *self.client_secret).password(true));
+ ui.label("In Google Picker, select the destination folder or an existing .tarvault file.");
+ } else {
+ ui.label("OneDrive requests Files.ReadWrite to browse your folders. The app writes only the vault you select/create.");
+ }
+ if ui.button("Sign in and select location").clicked() {
+ let provider = self.provider;
+ let secret = Zeroizing::new(std::mem::take(&mut *self.client_secret));
+ self.start(move || {
+ let login = cloud_auth::login(provider, secret)?;
+ let api = Arc::new(Api::new(login.provider, login.credential_id.clone()));
+ let result = (|| {
+ let selection = if provider == Provider::GoogleDrive { login.picked.as_deref().ok_or(DriveError::InvalidInput)? } else { "root" };
+ let item = api.item(selection)?;
+ let items = if item.folder { api.children(&item.id)? } else { Vec::new() };
+ Ok(ResultEvent::Connected(api, item, items))
+ })();
+ if result.is_err() { cloud_auth::disconnect(&login.credential_id)?; }
+ result
+ });
+ }
+ } else if let Some(api) = self.api.clone() {
+ if self.ready.is_none() {
+ ui.label(format!("Selected folder ID: {}", self.folder));
+ if self.provider == Provider::OneDrive && ui.button("Browse from OneDrive root").clicked() {
+ let api = api.clone();
+ self.start(move || { let root = api.item("root")?; Ok(ResultEvent::Listed(root.id.clone(), api.children(&root.id)?)) });
+ }
+ if self.provider == Provider::GoogleDrive { ui.label("Only files granted to this app are visible. Disconnect this draft to open Google Picker again."); }
+ let mut selected = None;
+ egui::ScrollArea::vertical().id_salt("drive_items").max_height(220.0).show(ui, |ui| {
+ for item in &self.items {
+ if (item.folder || item.name.ends_with(".tarvault")) && ui.button(format!("{} {}", if item.folder { "Folder:" } else { "Vault:" }, item.name)).clicked() {
+ selected = Some((item.id.clone(), item.folder));
+ }
+ }
+ });
+ if let Some((id, folder)) = selected {
+ let api = api.clone();
+ self.start(move || if folder { Ok(ResultEvent::Listed(id.clone(), api.children(&id)?)) }
+ else { Ok(ResultEvent::Selected(RemoteRef { provider: api.provider, credential_id: api.credential_id.clone(), file_id: id })) });
+ }
+ ui.label("New encrypted vault filename (.tarvault)"); ui.text_edit_singleline(&mut self.filename);
+ ui.label("New vault passphrase (at least 12 characters)");
+ ui.add(egui::TextEdit::singleline(&mut *self.passphrase).password(true));
+ if ui.button("Create encrypted vault in selected folder").clicked() {
+ let api = api.clone();
+ let folder = self.folder.clone(); let name = self.filename.clone();
+ let passphrase = Zeroizing::new(std::mem::take(&mut *self.passphrase));
+ self.start(move || {
+ let temp = tempfile::tempdir().map_err(|_| DriveError::Network)?;
+ let path = temp.path().join("new.tarvault");
+ let vault = LocalVault::create(&path, &passphrase).map_err(|_| DriveError::InvalidInput)?;
+ drop(vault);
+ let file = std::fs::File::open(&path).map_err(|_| DriveError::Network)?;
+ let encrypted = crate::drive::bounded(file, crate::drive::MAX_CIPHERTEXT)?;
+ Ok(ResultEvent::Selected(api.create(&folder, &name, &encrypted)?))
+ });
+ }
+ }
+ if let Some(remote) = &self.ready {
+ ui.label(format!("Selected vault ID: {}", remote.file_id));
+ if ui.button("Add cloud connection").clicked() {
+ result = Some(Connection { id: self.connection_id.clone(), location: Location::Drive { remote: remote.clone() } });
+ }
+ }
+ if ui.button("Disconnect this draft (keep remote files)").clicked() {
+ match cloud_auth::disconnect(&api.credential_id) {
+ Ok(()) => *self = Self::default(), Err(error) => self.message = error.to_string(),
+ }
+ }
+ }
+ });
+ result
+ }
+ pub(crate) fn saved(&mut self) {
+ *self = Self::default();
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index bfdfc94..de090b2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,12 @@
+mod azure;
+mod browser_import;
+mod cloud_auth;
pub mod core;
pub mod desktop;
+mod drive;
+mod drive_ui;
pub mod ipc;
pub mod scheduler;
+mod sources;
pub mod targets;
pub mod vault;
diff --git a/src/main.rs b/src/main.rs
index 0fb42c5..f76870e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -20,11 +20,31 @@ use zeroize::Zeroizing;
fn main() -> Result<(), Box> {
let args: Vec = env::args().collect();
- let root = env::var("TAR_VAULT_SYNC_DIR")
- .map(PathBuf::from)
- .unwrap_or(env::current_dir()?);
+ if args
+ .get(1)
+ .is_some_and(|mode| mode == "--version" || mode == "-V")
+ {
+ println!("TAR Vault Sync {}", env!("CARGO_PKG_VERSION"));
+ return Ok(());
+ }
+ if args
+ .get(1)
+ .is_some_and(|mode| mode == "--help" || mode == "-h")
+ {
+ println!("TAR Vault Sync\n\nUsage: tar-vault-sync [desktop|agent|config|vault|sync|status|logs|ack-restart|enable]\n\nNo arguments opens the desktop app.\nSet TAR_VAULT_SYNC_DIR to select a workspace.\nUse --version to print the application version.");
+ return Ok(());
+ }
+ let desktop = args.get(1).is_none_or(|mode| mode == "desktop");
+ let root = match env::var_os("TAR_VAULT_SYNC_DIR") {
+ Some(path) => PathBuf::from(path),
+ None if desktop => desktop_root()?,
+ None => env::current_dir()?,
+ };
+ if desktop {
+ fs::create_dir_all(&root)?;
+ }
let root = root.canonicalize()?;
- if args.get(1).is_some_and(|mode| mode == "desktop") {
+ if desktop {
return tar_vault_sync::desktop::run(root);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -33,6 +53,20 @@ fn main() -> Result<(), Box> {
runtime.block_on(async_main(args, root))
}
+fn desktop_root() -> Result> {
+ #[cfg(target_os = "windows")]
+ let base = PathBuf::from(env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA is unavailable")?);
+ #[cfg(target_os = "macos")]
+ let base = PathBuf::from(env::var_os("HOME").ok_or("HOME is unavailable")?)
+ .join("Library/Application Support");
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
+ let base = match env::var_os("XDG_DATA_HOME") {
+ Some(path) if PathBuf::from(&path).is_absolute() => PathBuf::from(path),
+ _ => PathBuf::from(env::var_os("HOME").ok_or("HOME is unavailable")?).join(".local/share"),
+ };
+ Ok(base.join("tar-vault-sync"))
+}
+
async fn async_main(args: Vec, root: PathBuf) -> Result<(), Box> {
let mode = args.get(1).map(String::as_str).unwrap_or("config");
let addr: SocketAddr = "127.0.0.1:43871".parse()?;
diff --git a/src/scheduler.rs b/src/scheduler.rs
index cb2f382..1474403 100644
--- a/src/scheduler.rs
+++ b/src/scheduler.rs
@@ -35,6 +35,19 @@ mod tests {
use std::{fs, sync::Mutex};
use tokio::net::TcpListener;
+ // Blocking providers run on worker threads. Wait for their observable result
+ // without advancing Tokio's paused clock or assuming one yield is enough.
+ async fn wait_until(ready: impl Fn() -> bool) {
+ let deadline = std::time::Instant::now() + Duration::from_secs(5);
+ while !ready() {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "provider worker did not finish"
+ );
+ tokio::task::yield_now().await;
+ }
+ }
+
#[tokio::test(start_paused = true)]
async fn settings_fixture_manual_and_scheduled_ipc_sync() {
let dir = tempfile::tempdir().unwrap();
@@ -107,7 +120,7 @@ mod tests {
let scheduler = tokio::spawn(run_binding(engine.clone(), "b1".into(), 2, 0));
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(2)).await;
- tokio::task::yield_now().await;
+ wait_until(|| engine.status().applied.get("b1") == Some(&SourceVersion("v2".into()))).await;
assert_eq!(*target.applies.lock().unwrap(), 2);
assert!(
matches!(request(addr,&Request::Status { token:"test-token".into() }).await.unwrap(),Response::Status { applied, .. } if applied==vec!["b1"])
@@ -169,7 +182,14 @@ mod tests {
tokio::task::yield_now().await;
assert_eq!(*store.reads.lock().unwrap(), (0, 0));
tokio::time::advance(Duration::from_secs(1)).await;
- tokio::task::yield_now().await;
+ wait_until(|| {
+ engine
+ .status()
+ .status
+ .get("b1")
+ .is_some_and(|status| status.outcome == SyncOutcome::Failed(ErrorCategory::Target))
+ })
+ .await;
assert_eq!(*store.reads.lock().unwrap(), (1, 1));
assert!(engine.status().applied.is_empty());
*target.fail.lock().unwrap() = false;
@@ -177,7 +197,7 @@ mod tests {
tokio::task::yield_now().await;
assert!(engine.status().applied.is_empty());
tokio::time::advance(Duration::from_millis(1)).await;
- tokio::task::yield_now().await;
+ wait_until(|| engine.status().applied.contains_key("b1")).await;
assert_eq!(engine.status().applied["b1"].0, "v1");
assert_eq!(*target.applies.lock().unwrap(), 1);
task.abort();
diff --git a/src/sources.rs b/src/sources.rs
new file mode 100644
index 0000000..b1cf485
--- /dev/null
+++ b/src/sources.rs
@@ -0,0 +1,509 @@
+//! Device-local source catalogue. It contains references only, never credentials.
+use crate::{
+ azure::AzureSource,
+ core::{
+ self, ErrorCategory, SecretPayload, SourceConnection, SourceRef, SourceVersion, StoreKind,
+ },
+ vault::VaultSource,
+};
+use fs2::FileExt;
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::BTreeMap,
+ fs,
+ io::{Read, Write},
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex, RwLock},
+};
+
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub(crate) enum Location {
+ FileSystem { path: PathBuf },
+ AzureKeyVault { vault_name: String },
+ Drive { remote: crate::drive::RemoteRef },
+}
+impl Location {
+ pub(crate) fn store(&self) -> StoreKind {
+ match self {
+ Self::FileSystem { .. } => StoreKind::LocalVault,
+ Self::AzureKeyVault { .. } => StoreKind::Azure,
+ Self::Drive { remote } => remote.provider.store(),
+ }
+ }
+ pub(crate) fn label(&self) -> &'static str {
+ match self {
+ Self::FileSystem { .. } => "File System",
+ Self::AzureKeyVault { .. } => "Azure Key Vault",
+ Self::Drive { remote } => remote.provider.label(),
+ }
+ }
+}
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Connection {
+ pub(crate) id: String,
+ pub(crate) location: Location,
+}
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Catalogue {
+ version: u32,
+ connections: Vec,
+}
+
+pub(crate) struct Sources {
+ local: Arc,
+ azure: AzureSource,
+ path: PathBuf,
+ saved_bytes: Mutex>>,
+ connections: RwLock>,
+ files: RwLock>>,
+}
+impl Sources {
+ pub(crate) fn open(root: &Path, local: Arc) -> Result {
+ let path = root.join("local/sources.json");
+ let saved_bytes = read_catalogue(&path)?;
+ let connections = match &saved_bytes {
+ Some(bytes) => {
+ let data: Catalogue =
+ serde_json::from_slice(bytes).map_err(|_| ErrorCategory::InvalidConfig)?;
+ if data.version != 1 || data.connections.len() > 64 {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ data.connections
+ }
+ None => Vec::new(),
+ };
+ let mut map = BTreeMap::new();
+ let mut files = BTreeMap::new();
+ for connection in connections {
+ validate(&connection)?;
+ if map.contains_key(&connection.id) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ if let Location::FileSystem { path } = &connection.location {
+ files.insert(
+ connection.id.clone(),
+ Arc::new(
+ VaultSource::new(path.clone(), connection.id.clone())
+ .map_err(|_| ErrorCategory::InvalidConfig)?,
+ ),
+ );
+ }
+ if let Location::Drive { remote } = &connection.location {
+ files.insert(
+ connection.id.clone(),
+ remote_source(root, &connection.id, remote)?,
+ );
+ }
+ map.insert(connection.id.clone(), connection);
+ }
+ Ok(Self {
+ local,
+ azure: AzureSource::new(),
+ path,
+ saved_bytes: Mutex::new(saved_bytes),
+ connections: RwLock::new(map),
+ files: RwLock::new(files),
+ })
+ }
+ pub(crate) fn list(&self) -> Result, ErrorCategory> {
+ Ok(self
+ .connections
+ .read()
+ .map_err(|_| ErrorCategory::State)?
+ .values()
+ .cloned()
+ .collect())
+ }
+ pub(crate) fn file(&self, id: &str) -> Result, ErrorCategory> {
+ if id == "local" {
+ return Ok(self.local.clone());
+ }
+ self.files
+ .read()
+ .map_err(|_| ErrorCategory::Source)?
+ .get(id)
+ .cloned()
+ .ok_or(ErrorCategory::InvalidConfig)
+ }
+ pub(crate) fn add(&self, connection: Connection) -> Result<(), ErrorCategory> {
+ validate(&connection)?;
+ let mut connections = self.connections.write().map_err(|_| ErrorCategory::State)?;
+ let mut files = self.files.write().map_err(|_| ErrorCategory::State)?;
+ if connections.len() >= 64 || connections.contains_key(&connection.id) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ let source = if let Location::FileSystem { path } = &connection.location {
+ Some(Arc::new(
+ VaultSource::new(path.clone(), connection.id.clone())
+ .map_err(|_| ErrorCategory::InvalidConfig)?,
+ ))
+ } else if let Location::Drive { remote } = &connection.location {
+ Some(remote_source(
+ self.path
+ .parent()
+ .and_then(Path::parent)
+ .ok_or(ErrorCategory::State)?,
+ &connection.id,
+ remote,
+ )?)
+ } else {
+ None
+ };
+ let mut next = connections.values().cloned().collect::>();
+ next.push(connection.clone());
+ persist(
+ &self.path,
+ &mut *self.saved_bytes.lock().map_err(|_| ErrorCategory::State)?,
+ &Catalogue {
+ version: 1,
+ connections: next,
+ },
+ )?;
+ if let Some(source) = source {
+ files.insert(connection.id.clone(), source);
+ }
+ connections.insert(connection.id.clone(), connection);
+ Ok(())
+ }
+ pub(crate) fn remove(&self, id: &str) -> Result<(), ErrorCategory> {
+ let mut connections = self.connections.write().map_err(|_| ErrorCategory::State)?;
+ let mut files = self.files.write().map_err(|_| ErrorCategory::State)?;
+ if !connections.contains_key(id) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ persist(
+ &self.path,
+ &mut *self.saved_bytes.lock().map_err(|_| ErrorCategory::State)?,
+ &Catalogue {
+ version: 1,
+ connections: connections
+ .values()
+ .filter(|c| c.id != id)
+ .cloned()
+ .collect(),
+ },
+ )?;
+ connections.remove(id);
+ if let Some(source) = files.remove(id) {
+ source.lock();
+ }
+ Ok(())
+ }
+ /// Renew credentials without repointing any existing binding to another vault.
+ pub(crate) fn reconnect(&self, connection: Connection) -> Result<(), ErrorCategory> {
+ validate(&connection)?;
+ let mut connections = self.connections.write().map_err(|_| ErrorCategory::State)?;
+ let old = connections
+ .get(&connection.id)
+ .ok_or(ErrorCategory::InvalidConfig)?;
+ let (Location::Drive { remote: previous }, Location::Drive { remote: next }) =
+ (&old.location, &connection.location)
+ else {
+ return Err(ErrorCategory::InvalidConfig);
+ };
+ if previous.provider != next.provider || previous.file_id != next.file_id {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ let mut files = self.files.write().map_err(|_| ErrorCategory::State)?;
+ let source = remote_source(
+ self.path
+ .parent()
+ .and_then(Path::parent)
+ .ok_or(ErrorCategory::State)?,
+ &connection.id,
+ next,
+ )?;
+ let updated = connections
+ .values()
+ .map(|c| {
+ if c.id == connection.id {
+ connection.clone()
+ } else {
+ c.clone()
+ }
+ })
+ .collect();
+ persist(
+ &self.path,
+ &mut *self.saved_bytes.lock().map_err(|_| ErrorCategory::State)?,
+ &Catalogue {
+ version: 1,
+ connections: updated,
+ },
+ )?;
+ if let Some(old) = files.insert(connection.id.clone(), source) {
+ old.lock();
+ }
+ connections.insert(connection.id.clone(), connection);
+ Ok(())
+ }
+ pub(crate) fn validate_ref(&self, source: &SourceRef) -> Result<(), ErrorCategory> {
+ let routed = self.routed(source)?;
+ match routed.store {
+ StoreKind::LocalVault => self.file(&routed.connection).map(|_| ()),
+ StoreKind::Azure if crate::azure::valid_source(&routed) => Ok(()),
+ _ => Err(ErrorCategory::InvalidConfig),
+ }
+ }
+ fn routed(&self, source: &SourceRef) -> Result {
+ if let Some(connection) = self
+ .connections
+ .read()
+ .map_err(|_| ErrorCategory::Source)?
+ .get(&source.connection)
+ {
+ if connection.location.store() != source.store {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ if let Location::AzureKeyVault { vault_name } = &connection.location {
+ return Ok(SourceRef {
+ store: StoreKind::Azure,
+ connection: vault_name.clone(),
+ entry: source.entry.clone(),
+ });
+ }
+ if matches!(connection.location, Location::Drive { .. }) {
+ return Ok(SourceRef {
+ store: StoreKind::LocalVault,
+ connection: source.connection.clone(),
+ entry: source.entry.clone(),
+ });
+ }
+ }
+ Ok(source.clone()) // Existing Azure bindings use the vault name directly.
+ }
+}
+impl SourceConnection for Sources {
+ fn get_version(&self, source: &SourceRef) -> Result {
+ let source = self.routed(source)?;
+ match source.store {
+ StoreKind::LocalVault => self.file(&source.connection)?.get_version(&source),
+ StoreKind::Azure => self.azure.get_version(&source),
+ _ => Err(ErrorCategory::InvalidConfig),
+ }
+ }
+ fn get_value(
+ &self,
+ source: &SourceRef,
+ version: &SourceVersion,
+ ) -> Result {
+ let source = self.routed(source)?;
+ match source.store {
+ StoreKind::LocalVault => self.file(&source.connection)?.get_value(&source, version),
+ StoreKind::Azure => self.azure.get_value(&source, version),
+ _ => Err(ErrorCategory::InvalidConfig),
+ }
+ }
+}
+fn validate(connection: &Connection) -> Result<(), ErrorCategory> {
+ if !core::safe_label(&connection.id) || connection.id == "local" {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ match &connection.location {
+ Location::FileSystem { path } => {
+ if !path.is_absolute()
+ || path
+ .components()
+ .any(|c| matches!(c, std::path::Component::ParentDir))
+ || path.parent().is_none_or(|p| !p.is_dir())
+ || fs::symlink_metadata(path)
+ .is_ok_and(|m| m.file_type().is_symlink() || !m.is_file())
+ {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ }
+ Location::AzureKeyVault { vault_name } => {
+ if connection.id != *vault_name {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ if !crate::azure::valid_source(&SourceRef {
+ store: StoreKind::Azure,
+ connection: vault_name.clone(),
+ entry: "validation".into(),
+ }) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ }
+ Location::Drive { remote } if remote.validate() => {}
+ Location::Drive { .. } => return Err(ErrorCategory::InvalidConfig),
+ }
+ Ok(())
+}
+fn remote_source(
+ root: &Path,
+ id: &str,
+ remote: &crate::drive::RemoteRef,
+) -> Result, ErrorCategory> {
+ let store = crate::drive::RemoteFile::new(remote).map_err(|_| ErrorCategory::InvalidConfig)?;
+ Ok(Arc::new(
+ VaultSource::remote(
+ root.join("local").join(format!("remote-{id}.bin")),
+ id.into(),
+ Arc::new(store),
+ )
+ .map_err(|_| ErrorCategory::InvalidConfig)?,
+ ))
+}
+fn read_catalogue(path: &Path) -> Result>, ErrorCategory> {
+ if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || !m.is_file()) {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ let file = match fs::File::open(path) {
+ Ok(file) => file,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(_) => return Err(ErrorCategory::State),
+ };
+ let mut bytes = Vec::new();
+ file.take(64 * 1024 + 1)
+ .read_to_end(&mut bytes)
+ .map_err(|_| ErrorCategory::State)?;
+ if bytes.len() > 64 * 1024 {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ Ok(Some(bytes))
+}
+fn persist(
+ path: &Path,
+ expected: &mut Option>,
+ data: &Catalogue,
+) -> Result<(), ErrorCategory> {
+ let lock_path = path.with_extension("lock");
+ if fs::symlink_metadata(&lock_path).is_ok_and(|m| m.file_type().is_symlink()) {
+ return Err(ErrorCategory::State);
+ }
+ let lock = fs::OpenOptions::new()
+ .create(true)
+ .truncate(false)
+ .read(true)
+ .write(true)
+ .open(lock_path)
+ .map_err(|_| ErrorCategory::State)?;
+ lock.try_lock_exclusive()
+ .map_err(|_| ErrorCategory::VersionConflict)?;
+ if read_catalogue(path)? != *expected {
+ return Err(ErrorCategory::VersionConflict);
+ }
+ let bytes = serde_json::to_vec_pretty(data).map_err(|_| ErrorCategory::State)?;
+ if bytes.len() > 64 * 1024 {
+ return Err(ErrorCategory::InvalidConfig);
+ }
+ let mut file = tempfile::NamedTempFile::new_in(path.parent().ok_or(ErrorCategory::State)?)
+ .map_err(|_| ErrorCategory::State)?;
+ file.write_all(&bytes)
+ .and_then(|_| file.as_file().sync_all())
+ .map_err(|_| ErrorCategory::State)?;
+ file.persist(path).map_err(|_| ErrorCategory::State)?;
+ *expected = Some(bytes);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::SecretType;
+
+ fn open(root: &Path) -> Sources {
+ fs::create_dir_all(root.join("local")).unwrap();
+ Sources::open(
+ root,
+ Arc::new(VaultSource::new(root.join("local/vault.bin"), "local".into()).unwrap()),
+ )
+ .unwrap()
+ }
+ fn connection(root: &Path, id: &str) -> Connection {
+ Connection {
+ id: id.into(),
+ location: Location::FileSystem {
+ path: root.join(format!("{id}.bin")),
+ },
+ }
+ }
+ #[test]
+ fn catalogue_routes_isolated_vaults_and_preserves_files_on_removal() {
+ let dir = tempfile::tempdir().unwrap();
+ let sources = open(dir.path());
+ for id in ["first", "second"] {
+ sources.add(connection(dir.path(), id)).unwrap();
+ }
+ let first = sources.file("first").unwrap();
+ first.create("synthetic-test-passphrase").unwrap();
+ let payload = SecretPayload::new(vec![17, 42, 91], SecretType::Binary);
+ first.put_entry("entry", &payload).unwrap();
+ let reference = SourceRef {
+ store: StoreKind::LocalVault,
+ connection: "first".into(),
+ entry: "entry".into(),
+ };
+ let version = sources.get_version(&reference).unwrap();
+ assert!(sources.get_value(&reference, &version).unwrap().as_bytes() == payload.as_bytes());
+ let mut wrong = reference.clone();
+ wrong.connection = "second".into();
+ assert!(matches!(
+ sources.get_version(&wrong),
+ Err(ErrorCategory::Unauthorized)
+ ));
+ wrong.store = StoreKind::Azure;
+ assert!(sources.validate_ref(&wrong).is_err());
+ first.put_entry("entry", &payload).unwrap();
+ assert!(matches!(
+ sources.get_value(&reference, &version),
+ Err(ErrorCategory::VersionConflict)
+ ));
+ let reopened = open(dir.path());
+ assert_eq!(reopened.list().unwrap().len(), 2);
+ assert!(!reopened.file("first").unwrap().is_unlocked());
+ sources.remove("first").unwrap();
+ assert!(dir.path().join("first.bin").exists());
+ assert!(!first.is_unlocked());
+ assert!(sources.file("first").is_err());
+ }
+ #[test]
+ fn stale_catalogue_is_not_overwritten() {
+ let dir = tempfile::tempdir().unwrap();
+ let first = open(dir.path());
+ let stale = open(dir.path());
+ first.add(connection(dir.path(), "first")).unwrap();
+ assert_eq!(
+ stale.add(connection(dir.path(), "stale")),
+ Err(ErrorCategory::VersionConflict)
+ );
+ assert!(stale.list().unwrap().is_empty());
+ assert!(stale.file("stale").is_err());
+ assert_eq!(open(dir.path()).list().unwrap().len(), 1);
+ }
+ #[test]
+ fn invalid_paths_duplicates_and_unknown_fields_fail_closed() {
+ let dir = tempfile::tempdir().unwrap();
+ let sources = open(dir.path());
+ assert!(sources.add(connection(dir.path(), "local")).is_err());
+ assert!(sources
+ .add(Connection {
+ id: "relative".into(),
+ location: Location::FileSystem {
+ path: "vault.bin".into()
+ }
+ })
+ .is_err());
+ sources.add(connection(dir.path(), "first")).unwrap();
+ assert!(sources.add(connection(dir.path(), "first")).is_err());
+ assert!(sources
+ .add(Connection {
+ id: "alias".into(),
+ location: Location::AzureKeyVault {
+ vault_name: "different-vault".into()
+ }
+ })
+ .is_err());
+ fs::write(
+ dir.path().join("local/sources.json"),
+ br#"{"version":1,"connections":[],"credentials":"rejected"}"#,
+ )
+ .unwrap();
+ let local =
+ Arc::new(VaultSource::new(dir.path().join("local/vault.bin"), "local".into()).unwrap());
+ assert!(Sources::open(dir.path(), local).is_err());
+ }
+}
diff --git a/src/vault.rs b/src/vault.rs
index a481486..4f6e4bf 100644
--- a/src/vault.rs
+++ b/src/vault.rs
@@ -17,7 +17,7 @@ use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
- sync::Mutex,
+ sync::{Arc, Mutex},
time::{Duration, Instant},
};
use zeroize::{Zeroize, Zeroizing};
@@ -28,6 +28,16 @@ const NONCE_LEN: usize = 24;
const MAX_VAULT_BYTES: u64 = 16 * 1024 * 1024;
const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
+/// A remote store must condition replacement on the revision from its last read.
+/// Callers serialize access through VaultSource's mutex. Bytes are ciphertext only.
+pub(crate) trait CipherStore: Send + Sync {
+ fn read(&self) -> Result, VaultError>;
+ fn replace(&self, bytes: &[u8]) -> Result<(), VaultError>;
+ fn disconnect(&self) -> Result<(), VaultError> {
+ Err(VaultError::InvalidInput)
+ }
+}
+
/// Errors never contain a password, path, entry ID, or secret value.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VaultError {
@@ -61,14 +71,14 @@ impl std::fmt::Display for VaultError {
}
impl std::error::Error for VaultError {}
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct EntryMeta {
kind: SecretType,
version: String,
}
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Manifest {
version: u32,
@@ -104,6 +114,7 @@ struct EnvelopeRef<'a> {
/// A vault stays unlocked only in this process and locks after 15 idle minutes.
pub struct LocalVault {
path: PathBuf,
+ remote: Option>,
salt: [u8; SALT_LEN],
key: Zeroizing<[u8; 32]>,
manifest: Manifest,
@@ -125,6 +136,7 @@ impl LocalVault {
let key = derive_key(passphrase, &salt)?;
let vault = Self {
path: path.to_path_buf(),
+ remote: None,
salt,
key,
manifest: Manifest {
@@ -140,11 +152,27 @@ impl LocalVault {
pub fn unlock(path: &Path, passphrase: &str) -> Result {
let data = read_limited(path)?;
- let envelope = parse_envelope(&data)?;
+ Self::open_bytes(path, passphrase, &data, None)
+ }
+
+ fn open_bytes(
+ path: &Path,
+ passphrase: &str,
+ data: &[u8],
+ remote: Option>,
+ ) -> Result {
+ if data.len() as u64 > MAX_VAULT_BYTES || passphrase.len() > 4096 {
+ return Err(VaultError::InvalidInput);
+ }
+ let envelope = parse_envelope(data)?;
let key = derive_key(passphrase, &envelope.salt)?;
let manifest = open_manifest(&envelope, &key)?;
+ if remote.is_some() {
+ verify_all(data, &key)?;
+ }
Ok(Self {
path: path.to_path_buf(),
+ remote,
salt: envelope.salt,
key,
manifest,
@@ -166,7 +194,7 @@ impl LocalVault {
return Err(VaultError::InvalidInput);
}
let _write_lock = lock_for_update(&self.path)?;
- self.reload()?;
+ self.reload_inner(true)?;
let version = loop {
let mut random = [0u8; 16];
OsRng.fill_bytes(&mut random);
@@ -204,7 +232,7 @@ impl LocalVault {
pub fn remove(&mut self, entry_id: &str) -> Result<(), VaultError> {
self.touch()?;
let _write_lock = lock_for_update(&self.path)?;
- self.reload()?;
+ self.reload_inner(true)?;
let old = self
.manifest
.entries
@@ -222,6 +250,63 @@ impl LocalVault {
Ok(())
}
+ /// Insert an approved import in one encrypted write, without overwriting entries.
+ pub(crate) fn import_new(
+ &mut self,
+ entries: &[(String, SecretPayload)],
+ ) -> Result<(), VaultError> {
+ self.touch()?;
+ let _write_lock = lock_for_update(&self.path)?;
+ self.reload_inner(true)?;
+ let mut ids = std::collections::BTreeSet::new();
+ let mut pending = Vec::new();
+ let mut versions = std::collections::BTreeSet::new();
+ if entries.is_empty() || entries.len() > 1000 {
+ return Err(VaultError::InvalidInput);
+ }
+ for (id, payload) in entries {
+ if !safe_label(id)
+ || !ids.insert(id)
+ || payload.as_bytes().len() > MAX_VAULT_BYTES as usize / 2
+ {
+ return Err(VaultError::InvalidInput);
+ }
+ if self.manifest.entries.contains_key(id) {
+ return Err(VaultError::AlreadyExists);
+ }
+ let version = loop {
+ let mut random = [0; 16];
+ OsRng.fill_bytes(&mut random);
+ let candidate = hex_version(&random);
+ if !self.sealed.contains_key(&candidate) && versions.insert(candidate.clone()) {
+ break candidate;
+ }
+ };
+ let sealed = seal_payload(&self.key, id, &version, payload.as_bytes())?;
+ pending.push((
+ id,
+ EntryMeta {
+ kind: payload.kind.clone(),
+ version,
+ },
+ sealed,
+ ));
+ }
+ for (id, meta, sealed) in pending {
+ self.sealed.insert(meta.version.clone(), sealed);
+ self.manifest.entries.insert(id.clone(), meta);
+ }
+ if let Err(error) = self.persist(false) {
+ for (id, _) in entries {
+ if let Some(meta) = self.manifest.entries.remove(id) {
+ self.sealed.remove(&meta.version);
+ }
+ }
+ return Err(error);
+ }
+ Ok(())
+ }
+
pub fn get_version(&mut self, entry_id: &str) -> Result {
self.touch()?;
self.reload()?;
@@ -234,7 +319,10 @@ impl LocalVault {
pub fn entries(&mut self) -> Result, VaultError> {
self.check_active()?;
- self.reload()?;
+ // Drawing the native UI must not issue a network request every frame.
+ if self.remote.is_none() {
+ self.reload()?;
+ }
Ok(self
.manifest
.entries
@@ -273,7 +361,7 @@ impl LocalVault {
if self.path == destination || destination.exists() {
return Err(VaultError::AlreadyExists);
}
- let data = read_limited(&self.path)?;
+ let data = self.read_ciphertext()?;
verify_all(&data, &self.key)?;
create_new_synced(destination, &data)
}
@@ -312,17 +400,31 @@ impl LocalVault {
}
fn reload(&mut self) -> Result<(), VaultError> {
- let data = read_limited(&self.path)?;
+ self.reload_inner(false)
+ }
+
+ fn reload_inner(&mut self, reject_remote_changes: bool) -> Result<(), VaultError> {
+ let data = self.read_ciphertext()?;
let envelope = parse_envelope(&data)?;
if envelope.salt != self.salt {
return Err(VaultError::AuthenticationOrCorrupt);
}
let manifest = open_manifest(&envelope, &self.key)?;
+ if self.remote.is_some() && reject_remote_changes && manifest != self.manifest {
+ return Err(VaultError::VersionConflict);
+ }
self.manifest = manifest;
self.sealed = envelope.sealed;
Ok(())
}
+ fn read_ciphertext(&self) -> Result, VaultError> {
+ match &self.remote {
+ Some(remote) => remote.read(),
+ None => read_limited(&self.path),
+ }
+ }
+
fn persist(&self, create: bool) -> Result<(), VaultError> {
let mut manifest_nonce = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut manifest_nonce);
@@ -353,7 +455,12 @@ impl LocalVault {
let mut output = Vec::with_capacity(MAGIC.len() + encoded.len());
output.extend_from_slice(MAGIC);
output.extend_from_slice(&encoded);
- if create {
+ if let Some(remote) = &self.remote {
+ if create {
+ return Err(VaultError::AlreadyExists);
+ }
+ remote.replace(&output)
+ } else if create {
create_new_synced(&self.path, &output)
} else {
atomic_replace(&self.path, &output)
@@ -601,6 +708,7 @@ pub struct VaultSource {
vault: Mutex>,
path: PathBuf,
connection: String,
+ remote: Option>,
}
#[derive(Serialize)]
pub struct VaultEntry {
@@ -617,28 +725,56 @@ impl VaultSource {
vault: Mutex::new(None),
path,
connection,
+ remote: None,
})
}
+ pub(crate) fn remote(
+ path: PathBuf,
+ connection: String,
+ remote: Arc,
+ ) -> Result {
+ let mut source = Self::new(path, connection)?;
+ source.remote = Some(remote);
+ Ok(source)
+ }
pub fn unlock(&self, passphrase: &str) -> Result<(), VaultError> {
- let opened = LocalVault::unlock(&self.path, passphrase)?;
- *self.vault.lock().map_err(|_| VaultError::Locked)? = Some(opened);
+ let mut guard = self.vault.lock().map_err(|_| VaultError::Locked)?;
+ let opened = match &self.remote {
+ Some(remote) => LocalVault::open_bytes(
+ &self.path,
+ passphrase,
+ &remote.read()?,
+ Some(remote.clone()),
+ )?,
+ None => LocalVault::unlock(&self.path, passphrase)?,
+ };
+ *guard = Some(opened);
Ok(())
}
pub fn create(&self, passphrase: &str) -> Result<(), VaultError> {
+ if self.remote.is_some() {
+ return Err(VaultError::AlreadyExists);
+ }
let created = LocalVault::create(&self.path, passphrase)?;
*self.vault.lock().map_err(|_| VaultError::Locked)? = Some(created);
Ok(())
}
pub fn recover(&self, backup: &Path, passphrase: &str) -> Result<(), VaultError> {
+ if self.remote.is_some() {
+ return Err(VaultError::AlreadyExists);
+ }
let recovered = LocalVault::recover(backup, &self.path, passphrase)?;
*self.vault.lock().map_err(|_| VaultError::Locked)? = Some(recovered);
Ok(())
}
pub fn exists(&self) -> bool {
- self.path.exists()
+ self.remote.is_some() || self.path.exists()
+ }
+ pub(crate) fn is_remote(&self) -> bool {
+ self.remote.is_some()
}
pub fn is_unlocked(&self) -> bool {
- self.vault.lock().is_ok_and(|mut guard| {
+ self.vault.try_lock().is_ok_and(|mut guard| {
if guard
.as_mut()
.is_some_and(|vault| vault.check_active().is_ok())
@@ -651,11 +787,18 @@ impl VaultSource {
})
}
pub fn entries(&self) -> Result, VaultError> {
- self.with_open(LocalVault::entries)
+ let mut guard = self.vault.try_lock().map_err(|_| VaultError::Locked)?;
+ guard.as_mut().ok_or(VaultError::Locked)?.entries()
}
pub fn put_entry(&self, id: &str, payload: &SecretPayload) -> Result<(), VaultError> {
self.with_open(|vault| vault.put(id, payload).map(|_| ()))
}
+ pub(crate) fn import_entries(
+ &self,
+ entries: &[(String, SecretPayload)],
+ ) -> Result<(), VaultError> {
+ self.with_open(|vault| vault.import_new(entries))
+ }
pub fn remove_entry(&self, id: &str) -> Result<(), VaultError> {
self.with_open(|vault| vault.remove(id))
}
@@ -679,6 +822,14 @@ impl VaultSource {
*guard = None;
}
}
+ pub(crate) fn disconnect(&self) -> Result<(), VaultError> {
+ let mut guard = self.vault.lock().map_err(|_| VaultError::Locked)?;
+ *guard = None;
+ self.remote
+ .as_ref()
+ .ok_or(VaultError::InvalidInput)?
+ .disconnect()
+ }
fn with_vault(
&self,
reference: &SourceRef,
@@ -753,6 +904,96 @@ impl SettingsScreen for VaultStoreScreen {
mod tests {
use super::*;
+ struct MemoryCloud {
+ bytes: Mutex>,
+ reject_write: std::sync::atomic::AtomicBool,
+ }
+ impl CipherStore for MemoryCloud {
+ fn read(&self) -> Result, VaultError> {
+ Ok(self.bytes.lock().unwrap().clone())
+ }
+ fn replace(&self, bytes: &[u8]) -> Result<(), VaultError> {
+ if self.reject_write.load(std::sync::atomic::Ordering::SeqCst) {
+ return Err(VaultError::VersionConflict);
+ }
+ *self.bytes.lock().unwrap() = bytes.to_vec();
+ Ok(())
+ }
+ }
+
+ #[test]
+ fn cloud_vault_authenticates_rotates_and_preserves_state_on_conflict() {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ let dir = tempfile::tempdir().unwrap();
+ let initial = dir.path().join("initial.bin");
+ let mut seed = LocalVault::create(&initial, "synthetic-passphrase").unwrap();
+ seed.put("sample", &SecretPayload::new(vec![1], SecretType::Binary))
+ .unwrap();
+ let cloud = Arc::new(MemoryCloud {
+ bytes: Mutex::new(fs::read(&initial).unwrap()),
+ reject_write: AtomicBool::new(false),
+ });
+ let cache_path = dir.path().join("not-persisted.bin");
+ let source =
+ VaultSource::remote(cache_path.clone(), "cloud".into(), cloud.clone()).unwrap();
+ assert!(source.unlock("incorrect-passphrase").is_err());
+ source.unlock("synthetic-passphrase").unwrap();
+ let reference = SourceRef {
+ store: StoreKind::LocalVault,
+ connection: "cloud".into(),
+ entry: "sample".into(),
+ };
+ let old = source.get_version(&reference).unwrap();
+ cloud.reject_write.store(true, Ordering::SeqCst);
+ let original = cloud.bytes.lock().unwrap().clone();
+ assert!(matches!(
+ source.put_entry("sample", &SecretPayload::new(vec![2], SecretType::Binary)),
+ Err(VaultError::VersionConflict)
+ ));
+ assert_eq!(*cloud.bytes.lock().unwrap(), original);
+ assert_eq!(source.get_version(&reference).unwrap(), old);
+ cloud.reject_write.store(false, Ordering::SeqCst);
+ source
+ .put_entry("sample", &SecretPayload::new(vec![3], SecretType::Binary))
+ .unwrap();
+ let current = source.get_version(&reference).unwrap();
+ assert_ne!(current, old);
+ assert!(matches!(
+ source.get_value(&reference, &old),
+ Err(ErrorCategory::VersionConflict)
+ ));
+ assert_eq!(
+ source.get_value(&reference, ¤t).unwrap().as_bytes(),
+ &[3]
+ );
+ assert!(
+ !cache_path.exists(),
+ "Cloud ciphertext must not be persisted as a local cache"
+ );
+ source.lock();
+ assert!(source.get_version(&reference).is_err());
+ source.unlock("synthetic-passphrase").unwrap();
+ // A second writer changes the authenticated manifest before an edit starts.
+ seed.put("another", &SecretPayload::new(vec![4], SecretType::Binary))
+ .unwrap();
+ *cloud.bytes.lock().unwrap() = fs::read(&initial).unwrap();
+ assert!(matches!(
+ source.put_entry("sample", &SecretPayload::new(vec![5], SecretType::Binary)),
+ Err(VaultError::VersionConflict)
+ ));
+ source.unlock("synthetic-passphrase").unwrap();
+ source
+ .put_entry("sample", &SecretPayload::new(vec![6], SecretType::Binary))
+ .unwrap();
+ // Tampered cloud payloads fail authentication without delivering plaintext.
+ let mut envelope = parse_envelope(&cloud.bytes.lock().unwrap()).unwrap();
+ envelope.manifest_ciphertext[0] ^= 1;
+ let mut damaged = MAGIC.to_vec();
+ damaged.extend(serde_json::to_vec(&envelope).unwrap());
+ *cloud.bytes.lock().unwrap() = damaged;
+ assert!(source.get_version(&reference).is_err());
+ }
+
#[test]
fn roundtrip_rotation_and_recovery() {
let dir = tempfile::tempdir().unwrap();
diff --git a/tests/cli.rs b/tests/cli.rs
new file mode 100644
index 0000000..a110668
--- /dev/null
+++ b/tests/cli.rs
@@ -0,0 +1,19 @@
+use std::process::Command;
+
+#[test]
+fn informational_commands_do_not_open_or_create_a_workspace() {
+ let temp = tempfile::tempdir().unwrap();
+ let missing = temp.path().join("not-created");
+ for option in ["--version", "--help"] {
+ let result = Command::new(env!("CARGO_BIN_EXE_tar-vault-sync"))
+ .arg(option)
+ .env("TAR_VAULT_SYNC_DIR", &missing)
+ .output()
+ .unwrap();
+ assert!(result.status.success());
+ assert!(String::from_utf8(result.stdout)
+ .unwrap()
+ .contains("TAR Vault Sync"));
+ assert!(!missing.exists());
+ }
+}
diff --git a/tests/test_analytics.cjs b/tests/test_analytics.cjs
new file mode 100644
index 0000000..65c1e5d
--- /dev/null
+++ b/tests/test_analytics.cjs
@@ -0,0 +1,49 @@
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const { readFileSync } = require("node:fs");
+const vm = require("node:vm");
+const source = readFileSync(new URL("../docs/assets/analytics.js", "file://" + __filename.replaceAll("\\", "/")), "utf8");
+
+function run(hostname = "tarvault.tarsolution.com", saved = null) {
+ const elements = [];
+ const node = () => ({
+ style: {}, children: [], handlers: {},
+ setAttribute() {}, append(...items) { this.children.push(...items); },
+ addEventListener(event, handler) { this.handlers[event] = handler; }
+ });
+ const document = { head: node(), body: node(), cookie: "",
+ createElement(tag) { const el = node(); el.tag = tag; elements.push(el); return el; } };
+ const window = {};
+ const localStorage = { getItem() { return saved; }, setItem(key, value) { saved = value; } };
+ vm.runInNewContext(source, { document, window, localStorage,
+ location: { hostname, origin: "https://" + hostname, pathname: "/releases.html",
+ search: "?private=example", hash: "#private" } });
+ return { document, window, click(label) {
+ elements.find(el => el.textContent === label).handlers.click();
+ } };
+}
+
+test("no Google request before consent, or on local/private Sites copies", () => {
+ for (const [host, choice] of [["localhost", "accepted"], ["tar-vault-sync.fmarslan.chatgpt.site", "accepted"],
+ ["tarvault.tarsolution.com", null], ["tarvault.tarsolution.com", "declined"]]) {
+ assert.equal(run(host, choice).document.head.children.length, 0);
+ }
+});
+
+test("acceptance loads once, strips URL parameters, and supports withdrawal", () => {
+ const app = run();
+ app.click("Allow analytics");
+ app.click("Allow analytics");
+ assert.equal(app.document.head.children.length, 1);
+ assert.match(app.document.head.children[0].src, /id=G-MG2LD1T304$/);
+ const config = app.window.dataLayer.find(args => args[0] === "config")[2];
+ assert.equal(config.page_location, "https://tarvault.tarsolution.com/releases.html");
+ assert.equal(config.page_referrer, "");
+ assert.equal(config.allow_google_signals, false);
+ app.click("Decline");
+ assert.equal(app.window["ga-disable-G-MG2LD1T304"], true);
+ app.click("Allow analytics");
+ assert.equal(app.window["ga-disable-G-MG2LD1T304"], false);
+ assert.equal(app.document.head.children.length, 1);
+ assert.equal(run("tarvault.tarsolution.com", "accepted").document.head.children.length, 1);
+});
diff --git a/tests/test_docs.py b/tests/test_docs.py
new file mode 100644
index 0000000..8a4de20
--- /dev/null
+++ b/tests/test_docs.py
@@ -0,0 +1,113 @@
+"""Keep the static guide self-contained and its published links valid."""
+
+from html.parser import HTMLParser
+from pathlib import Path
+import unittest
+import importlib.util
+from urllib.parse import urlsplit
+
+
+class GuideParser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.ids = set()
+ self.links = []
+ self.scripts = []
+ self.images = []
+ self.meta = {}
+ self.icons = []
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ if tag == "meta":
+ self.meta[attrs.get("property", attrs.get("name"))] = attrs.get("content")
+ if tag == "link" and attrs.get("rel") == "icon":
+ self.icons.append(attrs.get("href"))
+ if "id" in attrs:
+ self.ids.add(attrs["id"])
+ for key in ("href", "src"):
+ if key in attrs:
+ self.links.append(attrs[key])
+ if tag == "script":
+ self.scripts.append(attrs)
+ if tag == "img":
+ self.images.append(attrs)
+
+
+class GuideTests(unittest.TestCase):
+ def test_branding_in_pages_and_catalog_template(self):
+ root = Path(__file__).resolve().parents[1]
+ spec = importlib.util.spec_from_file_location("catalog", root / "scripts/update-docs-catalog.py")
+ catalog = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(catalog)
+ sources = [(root / "docs" / name).read_text(encoding="utf-8")
+ for name in ("index.html", "releases.html", "credits.html")]
+ sources.append(catalog.page("Releases", "", "test"))
+ for source in sources:
+ parser = GuideParser()
+ parser.feed(source)
+ self.assertIn("assets/mark.png", parser.icons)
+ self.assertTrue((root / "docs/assets/mark.png").is_file())
+ for key in ("og:image", "twitter:image"):
+ self.assertEqual(parser.meta[key], "https://tarvault.tarsolution.com/assets/mark.png")
+ self.assertEqual(parser.meta["twitter:card"], "summary")
+ self.assertEqual(parser.meta["og:image:alt"], "TAR Vault Sync logo")
+ self.assertEqual(parser.scripts, [{"defer": None, "src": "assets/analytics.js"}])
+
+ def test_catalogue_pages(self):
+ root = Path(__file__).resolve().parents[1]
+ for name in ("index.html", "releases.html", "credits.html"):
+ source = (root / "docs" / name).read_text(encoding="utf-8")
+ parser = GuideParser()
+ parser.feed(source)
+ self.assertEqual(parser.scripts, [{"defer": None, "src": "assets/analytics.js"}])
+ self.assertIn("A TAR Solution project", source)
+ self.assertIn("Developed by fmarslan.com", source)
+ for target in ("https://fmarslan.com/", "releases.html", "credits.html"):
+ self.assertIn(target, parser.links)
+ for target in parser.links:
+ parts = urlsplit(target)
+ if not parts.scheme and parts.path:
+ self.assertTrue((root / "docs" / parts.path).is_file(), target)
+ spec = importlib.util.spec_from_file_location("catalog", root / "scripts/update-docs-catalog.py")
+ catalog = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(catalog)
+ empty = catalog.releases_content([])
+ self.assertIn("No stable release", empty)
+ records = [dict(name="", tag_name="v2", html_url="https://github.com/test/release",
+ published_at="2026-09-20T00:00:00Z", prerelease=True),
+ dict(name="Stable", tag_name="v1", html_url="https://github.com/test/stable",
+ published_at="2026-09-19T00:00:00Z", prerelease=False)]
+ rendered = catalog.releases_content(records)
+ latest, archive = rendered.split('id="archive"')
+ self.assertIn("Stable", latest)
+ self.assertNotIn("<Preview>", latest)
+ self.assertIn("<Preview>", archive)
+ self.assertIn("Pre-release", archive)
+ with self.assertRaises(ValueError):
+ catalog.link("javascript:alert(1)", "unsafe")
+
+ def test_static_guide_assets_and_navigation(self):
+ root = Path(__file__).resolve().parents[1] / "docs"
+ parser = GuideParser()
+ parser.feed((root / "index.html").read_text(encoding="utf-8"))
+ self.assertEqual(parser.scripts, [{"defer": None, "src": "assets/analytics.js"}])
+ self.assertIn("https://fmarslan.com/", parser.links)
+ self.assertEqual(len(parser.images), 7)
+ for image in parser.images:
+ self.assertIn("alt", image)
+ for link in parser.links:
+ parts = urlsplit(link)
+ if parts.scheme:
+ self.assertEqual(parts.scheme, "https")
+ elif parts.path:
+ target = (root / parts.path).resolve()
+ self.assertTrue(target.is_relative_to(root.resolve()))
+ self.assertTrue(target.is_file(), link)
+ elif parts.fragment:
+ self.assertIn(parts.fragment, parser.ids)
+ self.assertEqual((root / "CNAME").read_text().strip(), "tarvault.tarsolution.com")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_release.py b/tests/test_release.py
new file mode 100644
index 0000000..200bbca
--- /dev/null
+++ b/tests/test_release.py
@@ -0,0 +1,53 @@
+from pathlib import Path
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+import zipfile
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class ReleasePackagingTests(unittest.TestCase):
+ def test_complete_matrix_and_tamper_detection(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ (workspace / "Cargo.toml").write_text('[package]\nversion = "0.1.0"\n', encoding="utf-8")
+ (workspace / "docs").mkdir()
+ (workspace / "docs/release-notes.md").write_text("Test package", encoding="utf-8")
+ for platform in ("windows", "macos", "linux"):
+ for arch in ("amd64", "arm64"):
+ target = f"test-{platform}-{arch}"
+ folder = workspace / "target" / target / "release"
+ folder.mkdir(parents=True)
+ executable = "tar-vault-sync.exe" if platform == "windows" else "tar-vault-sync"
+ (folder / executable).write_bytes(b"packaging-test-executable")
+ subprocess.run([sys.executable, str(ROOT / "scripts/package-release.py"),
+ "--platform", platform, "--arch", arch, "--target", target],
+ cwd=workspace, check=True, capture_output=True)
+ verify = [sys.executable, str(ROOT / "scripts/verify-release.py"), str(workspace / "dist")]
+ subprocess.run(verify, check=True, capture_output=True)
+ self.assertEqual(len((workspace / "dist/SHA256SUMS").read_text().splitlines()), 6)
+ mac = workspace / "dist/tar-vault-sync-0.1.0-macos-arm64.zip"
+ with zipfile.ZipFile(mac) as archive:
+ names = archive.namelist()
+ self.assertTrue(any(name.endswith(".app/Contents/Info.plist") for name in names))
+ binary = next(item for item in archive.infolist() if item.filename.endswith("/MacOS/tar-vault-sync"))
+ self.assertTrue((binary.external_attr >> 16) & 0o111)
+ self.assertEqual(binary.create_system, 3)
+ readme = next(item for item in archive.infolist() if item.filename.endswith("/README.md"))
+ self.assertFalse((readme.external_attr >> 16) & 0o111)
+ with tarfile.open(workspace / "dist/tar-vault-sync-0.1.0-linux-arm64.tar.gz") as archive:
+ self.assertEqual(archive.getmember("tar-vault-sync-0.1.0-linux-arm64/tar-vault-sync").mode, 0o755)
+ self.assertEqual(archive.getmember("tar-vault-sync-0.1.0-linux-arm64/README.md").mode, 0o644)
+ with mac.open("ab") as stream:
+ stream.write(b"corrupt")
+ self.assertNotEqual(subprocess.run(verify, capture_output=True).returncode, 0)
+ mac.unlink()
+ self.assertNotEqual(subprocess.run(verify, capture_output=True).returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()