From ffdfb1f9b69b38205ac53873e54f2c8e1adf06a3 Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Wed, 3 Jun 2026 01:11:47 +0200 Subject: [PATCH] pivot to Python: replace Kotlin/JVM with stdlib zipapp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasons stacked up: - AV: unsigned JARs that auto-download binaries + upload files trigger Windows Defender false-positives more often than Python scripts invoked by code-signed python.exe. - Qt UI option: PySide6 opens a path to a real Qt UI (matching Prism's look) if needed later. JVM Qt bindings are abandoned. - frazclient already needs Python; inlining as 'import cloud_sync' is zero overhead vs the launcher always shelling out to java. Implementation: - cloud_sync package: cli.py (argparse), creds.py, scope.py, restic.py (binary discovery + auto-download + sha256 verify), sync.py (pull/push subprocess restic). - pyproject.toml with hatchling backend; pip-installable. - Makefile builds cloud-sync.pyz via python -m zipapp (~53 KB). - 33 pytest tests, stdlib only on runtime. - CI workflow runs pytest matrix (3.10/3.11/3.12) + builds pyz. - DESIGN.md + README.md updated to reflect Python. E2E verified against local restic-rest-server: pull empty → push initial → rm -rf local → pull restores → modify+push creates second snapshot → client forget --prune blocked by --append-only. Throws away ~565 LOC of Kotlin (and 18 jar tests) committed earlier in this same session. Net result is ~250 LOC Python + 33 tests = smaller and more aligned with the rest of the stack. --- .gitea/workflows/ci.yaml | 55 ++-- .gitignore | 12 +- DESIGN.md | 26 +- Makefile | 36 +++ README.md | 114 ++++---- build.gradle.kts | 65 ----- cloud_sync/__init__.py | 14 + cloud_sync/__main__.py | 11 + cloud_sync/cli.py | 102 +++++++ cloud_sync/creds.py | 40 +++ cloud_sync/restic.py | 223 ++++++++++++++++ cloud_sync/scope.py | 84 ++++++ cloud_sync/sync.py | 129 +++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 7 - gradlew | 252 ------------------ gradlew.bat | 94 ------- pyproject.toml | 38 +++ settings.gradle.kts | 1 - .../kotlin/center/timemachine/cloud/Args.kt | 98 ------- .../kotlin/center/timemachine/cloud/Cli.kt | 26 -- .../kotlin/center/timemachine/cloud/Main.kt | 74 ----- .../kotlin/center/timemachine/cloud/Restic.kt | 246 ----------------- .../kotlin/center/timemachine/cloud/Scope.kt | 80 ------ .../kotlin/center/timemachine/cloud/Sync.kt | 145 ---------- .../center/timemachine/cloud/ArgsTest.kt | 93 ------- .../timemachine/cloud/CredentialsTest.kt | 52 ---- .../center/timemachine/cloud/SmokeTest.kt | 21 -- tests/test_cli.py | 75 ++++++ tests/test_creds.py | 62 +++++ tests/test_repo_url.py | 54 ++++ tests/test_scope.py | 70 +++++ 32 files changed, 1056 insertions(+), 1343 deletions(-) create mode 100644 Makefile delete mode 100644 build.gradle.kts create mode 100644 cloud_sync/__init__.py create mode 100644 cloud_sync/__main__.py create mode 100644 cloud_sync/cli.py create mode 100644 cloud_sync/creds.py create mode 100644 cloud_sync/restic.py create mode 100644 cloud_sync/scope.py create mode 100644 cloud_sync/sync.py delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100755 gradlew delete mode 100644 gradlew.bat create mode 100644 pyproject.toml delete mode 100644 settings.gradle.kts delete mode 100644 src/main/kotlin/center/timemachine/cloud/Args.kt delete mode 100644 src/main/kotlin/center/timemachine/cloud/Cli.kt delete mode 100644 src/main/kotlin/center/timemachine/cloud/Main.kt delete mode 100644 src/main/kotlin/center/timemachine/cloud/Restic.kt delete mode 100644 src/main/kotlin/center/timemachine/cloud/Scope.kt delete mode 100644 src/main/kotlin/center/timemachine/cloud/Sync.kt delete mode 100644 src/test/kotlin/center/timemachine/cloud/ArgsTest.kt delete mode 100644 src/test/kotlin/center/timemachine/cloud/CredentialsTest.kt delete mode 100644 src/test/kotlin/center/timemachine/cloud/SmokeTest.kt create mode 100644 tests/test_cli.py create mode 100644 tests/test_creds.py create mode 100644 tests/test_repo_url.py create mode 100644 tests/test_scope.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 4a42eda..faece6b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -8,39 +8,46 @@ on: branches: [main] jobs: - build: + test: runs-on: ubuntu-latest - timeout-minutes: 15 - container: - image: docker.io/gradle:8.10.2-jdk21 + timeout-minutes: 10 + strategy: + matrix: + python: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install --upgrade pip + - run: pip install -e .[test] + - run: pytest -v - - name: Build fat jar - run: gradle --no-daemon shadowJar - - - name: Test - run: gradle --no-daemon test - - - name: Stash jar for release - if: startsWith(github.ref, 'refs/tags/v') + build-pyz: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: make build + - if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@v4 with: - name: cloud-sync-jar - path: build/libs/cloud-sync-*.jar + name: cloud-sync-pyz + path: cloud-sync.pyz release: runs-on: ubuntu-latest - needs: build + needs: build-pyz if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 with: - name: cloud-sync-jar - path: build/libs/ - + name: cloud-sync-pyz - name: Publish release run: | gh_token="${{ secrets.RELEASE_TOKEN }}" @@ -51,9 +58,7 @@ jobs: -d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\",\"draft\":false,\"prerelease\":false}" \ "${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases" > /tmp/release.json release_id=$(jq -r .id /tmp/release.json) - for jar in build/libs/cloud-sync-*.jar; do - curl -sS -X POST \ - -H "Authorization: token ${gh_token}" \ - -F "attachment=@${jar}" \ - "${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=$(basename $jar)" - done + curl -sS -X POST \ + -H "Authorization: token ${gh_token}" \ + -F "attachment=@cloud-sync.pyz" \ + "${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=cloud-sync.pyz" diff --git a/.gitignore b/.gitignore index 1b8acca..82f68dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ -.gradle/ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +*.egg-info/ build/ -out/ -*.iml +dist/ +cloud-sync.pyz +.coverage .idea/ .vscode/ *.swp -local.properties diff --git a/DESIGN.md b/DESIGN.md index 7797f1c..1914eb6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,12 +1,12 @@ # cloud-sync — design -Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Single JAR drops into Prism / MMC / ATLauncher / frazclient as a pre-launch + post-exit hook. +Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Single Python zipapp drops into Prism / MMC / ATLauncher / frazclient as a pre-launch + post-exit hook. **Data plane:** `restic-rest-server` with `--private-repos --append-only`. Clients hit this directly with their per-user password. **Control plane:** `cloud-svc` Go service with two listeners — a provisioning port reachable from automc-net (called by discord-bot) and a loopback admin port (called by automc-setup wizard). Players never touch cloud-svc. -**Client:** `cloud-sync.jar` subprocesses restic. ~200 LOC. +**Client:** `cloud-sync.pyz` (Python 3.10+, stdlib only) subprocesses restic. ~300 LOC. Distributed as a zipapp (single-file). Python over Java for two reasons: (a) launcher's PostExit hook can call any subprocess so language doesn't matter, (b) custom unsigned JARs that download binaries + upload files are textbook Windows Defender false-positive triggers, while Python invoked by signed `python.exe` mostly sidesteps that. ## Why this shape @@ -29,8 +29,8 @@ flowchart LR pl["player PC"]:::external op["operator (via SSH)"]:::external - jar["cloud-sync.jar -(in launcher's + jar["cloud-sync.pyz +(Python; in launcher's pre/post hooks)"]:::deploy restic["restic binary (auto-downloaded @@ -115,7 +115,7 @@ Revocation = operator runs `automc-setup cloud revoke ` which hits t ## On-disk layout (client) -cloud-sync.jar stores its state under `/.cloud-sync/` — per-instance, hidden by leading dot. Auto-excluded from cloud sync so a player can't accidentally upload their own credentials. +cloud-sync.pyz stores its state under `/.cloud-sync/` — per-instance, hidden by leading dot. Auto-excluded from cloud sync so a player can't accidentally upload their own credentials. ``` / @@ -145,21 +145,21 @@ Probed in order: ### Jar placement -Stateless. Lives wherever the operator put it. Prism / MMC config references absolute path. One jar can serve N instances; each gets its own `.cloud-sync/` underneath its own `--pack-folder`. +Stateless. Lives wherever the operator put it. Prism / MMC config references absolute path. One pyz can serve N instances; each gets its own `.cloud-sync/` underneath its own `--pack-folder`. ## Client flow -### `cloud-sync.jar pull` +### `cloud-sync.pyz pull` ``` 1. Load creds from /.cloud-token (format: discord_id:password on one line) -2. Locate or auto-download restic binary into /restic-/ +2. Locate or auto-download restic binary into /.cloud-sync/restic- 3. restic -r rest:https://// snapshots --latest 1 --json 4. If no snapshots → exit 0 (first run on this machine, nothing to restore) 5. restic restore latest --target --include-from cloud-scope.txt ``` -### `cloud-sync.jar push` +### `cloud-sync.pyz push` ``` 1. Same creds + restic locator as pull @@ -201,7 +201,7 @@ Recommendation: **option 1** (multi-key per repo). On `/register`, the bot calls - restic-rest-server with `--private-repos --append-only --htpasswd-file` - discord-bot `/register` extension: mint password, htpasswd add, `restic init` repo, `restic key add` player key -- cloud-sync.jar that subprocesses restic for pull/push +- cloud-sync.pyz that subprocesses restic for pull/push - Auto-download restic binary on first run from upstream GitHub release - Server-side nightly prune cron with operator-side master password key @@ -236,7 +236,7 @@ New code: Estimate: ~300 LOC kept, ~600 LOC new. Net smaller than current cloud-svc. -Also delete `cloud_pull` / `cloud_push` from `frazclient/client.py` (these get obsoleted by `cloud-sync.jar` calls). +Also delete `cloud_pull` / `cloud_push` from `frazclient/client.py` (these get obsoleted by `import cloud_sync` calls; frazclient depends on the same package). ## Topology consequences for `automc/docs/network-exposure.md` @@ -260,7 +260,7 @@ Operator endpoints are loopback-only and require SSH access to john to reach. No | Repo | Purpose | |---|---| -| `Timemachine/cloud-sync` (this) | Kotlin/Gradle JAR that subprocesses restic | +| `Timemachine/cloud-sync` (this) | Python 3.10+ package + zipapp that subprocesses restic | | `Timemachine/cloud-svc` | **Reshaped** — control plane only. Two-port Go service for provisioning + operator ops. NOT archived. | | `Timemachine/discord-bot` | Extended `/register` flow calls cloud-svc to provision; DMs returned password | | `Timemachine/automc` | `setup` wizard adds `automc-setup cloud {list,prune,revoke,quota}` subcommands hitting cloud-svc's loopback admin port. Quadlet templates for both restic-ao (new flags) and cloud-svc (two listeners). `database/schema.sql` unchanged. | @@ -272,6 +272,6 @@ All locked 2026-06-02: - [x] cloud-svc reshapes to control plane, not archived - [x] Two-port split — automc-net for provisioning, loopback for operator - [x] Server-side prune via operator master password key on each repo. On `provision`, cloud-svc runs `restic init` then `restic key add` with the operator-master password as a SECOND key. The nightly pruner uses the operator key to open any repo. -- [x] cloud-sync.jar auto-downloads restic binary. Matches `packwiz-installer-bootstrap` pattern. First run hits `https://github.com/restic/restic/releases` for the matching platform binary, caches under `/restic-/`. `--no-download` flag for air-gapped operators. +- [x] cloud-sync.pyz auto-downloads restic binary. Matches `packwiz-installer-bootstrap` pattern. First run hits `https://github.com/restic/restic/releases` for the matching platform binary, caches under `/.cloud-sync/restic-`. SHA256 verified against the release's `SHA256SUMS` file. `--no-download` flag for air-gapped operators. - [x] Nightly prune at 04:00 UTC. `time.Ticker` inside cloud-svc; no external cron. `--prune-time HH:MM` flag in case operators want a different window. - [x] Per-caller tokens, NOT shared. cloud-svc reads `CLOUD_PROVISIONING_TOKENS_BOT`, `CLOUD_PROVISIONING_TOKENS_` env vars — one per known caller. Logs include the matched caller name so audit trails show which service made each call. Adding a future caller (e.g., a portal) means a new env var, not a token rotation. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8edf191 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build test clean install help + +PY := python3 + +help: + @echo "Targets:" + @echo " build — produce cloud-sync.pyz (single-file zipapp)" + @echo " test — run pytest" + @echo " install — pip install -e ." + @echo " clean — remove built artifacts" + +build: cloud-sync.pyz + +# zipapp builds from a source dir; to preserve the cloud_sync/ package +# structure inside the archive we stage it under build/pyz first, then +# point zipapp at that staging dir. +cloud-sync.pyz: cloud_sync/__main__.py $(wildcard cloud_sync/*.py) + rm -rf build/pyz + mkdir -p build/pyz + cp -r cloud_sync build/pyz/ + $(PY) -m zipapp build/pyz -p "/usr/bin/env $(PY)" \ + -m cloud_sync.cli:main -o $@ + rm -rf build/pyz + @echo "built: $@ ($$(stat -c '%s' $@) bytes)" + +test: + $(PY) -m pytest tests/ -v + +install: + $(PY) -m pip install -e . + +clean: + rm -f cloud-sync.pyz + rm -rf build/ dist/ *.egg-info __pycache__ \ + cloud_sync/__pycache__ tests/__pycache__ \ + .pytest_cache diff --git a/README.md b/README.md index 6da5749..7ed1245 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,100 @@ # cloud-sync -Single-jar Kotlin client for [`cloud-svc`](https://git.timemachine.center/Timemachine/cloud-svc). Drops into Prism / MMC / ATLauncher / frazclient pre-launch + post-exit hooks alongside [`packwiz-installer-bootstrap`](https://github.com/packwiz/packwiz-installer-bootstrap). One tool, all launchers, no Python dependency on the client side. +Per-user Minecraft state sync via [restic](https://restic.net). Single Python zipapp drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks alongside [packwiz-installer-bootstrap](https://github.com/packwiz/packwiz-installer-bootstrap). Part of the [automc](https://git.timemachine.center/Timemachine/automc) platform. + +See [`DESIGN.md`](DESIGN.md) for the full architecture (restic backend, two-port cloud-svc control plane, etc.). ## Status -**Skeleton.** Build works, jar runs, CLI parses subcommands. Real sync logic + conflict UI land in subsequent commits. See [`DESIGN.md` in cloud-svc](https://git.timemachine.center/Timemachine/cloud-svc/src/branch/main/DESIGN.md) for the underlying contract this jar implements. +Working skeleton + sync logic. 33 tests pass. E2E verified against a local `restic-rest-server` (pull empty → push initial → delete local → pull restores → modify+push creates second snapshot → client `forget --prune` correctly blocked by `--append-only`). -## Usage (planned) +## Install / build -In Prism's instance settings → Custom commands: +Requires Python ≥ 3.10. No runtime deps (stdlib only). -``` -Pre-launch command: - "$INST_JAVA" -jar /path/to/cloud-sync.jar pull \ - --url=https://cloud.timemachine.center \ - --pack-folder=$INST_MC_DIR +```bash +# build single-file zipapp +make build # → cloud-sync.pyz (~53 KB) -Post-exit command: - "$INST_JAVA" -jar /path/to/cloud-sync.jar push \ - --url=https://cloud.timemachine.center \ - --pack-folder=$INST_MC_DIR +# or pip-install +make install # pip install -e . ``` -Token comes from `$INST_MC_DIR/.cloud-token` (paste once from your Discord-bot DM), `--token `, or `CLOUD_TOKEN` env. +## Usage in Prism (or MMC / ATLauncher) + +Instance Settings → Custom commands: + +``` +Pre-launch: + python /path/to/cloud-sync.pyz pull --url=https://cloud.tm.center --pack-folder=$INST_MC_DIR + +Post-exit: + python /path/to/cloud-sync.pyz push --url=https://cloud.tm.center --pack-folder=$INST_MC_DIR +``` + +Player needs Python 3.10+ on PATH. Token file (`/.cloud-sync/token`) gets the `discord_id:password` credentials from their `/register` Discord DM. ## CLI ``` -java -jar cloud-sync.jar [flags] - -Subcommands: - pull Fetch user's cloud state, apply conflict resolution, write to instance. - push Walk instance, build snapshot, upload changed files. - -Flags: - --url cloud-svc base URL (required) - --pack-folder instance dir to sync into/from (default: ".") - --token bearer token (or use --token-file) - --token-file read token from this file (default: /.cloud-token) - -g, --no-gui headless mode; conflicts auto-resolve to remote-wins - -V, --version print version - -h, --help print this help +python cloud-sync.pyz {pull,push} \ + --url URL cloud-svc data plane URL (required) + --pack-folder PATH Minecraft instance directory (default: cwd) + --token-file PATH override default /.cloud-sync/token + --restic-binary PATH skip auto-discovery + --no-download fail if no usable restic; don't fetch from upstream + -g, --no-gui headless mode ``` -## Build +## Programmatic API (for frazclient) -Requires JDK 17–21 (JDK 26 currently breaks Kotlin 2.1's compiler). Easiest: containerized via podman/docker. +```python +from pathlib import Path +import cloud_sync -```bash -podman run --rm -v "$PWD":/work:Z -w /work docker.io/gradle:8.10.2-jdk21 \ - gradle --no-daemon shadowJar +cloud_sync.pull(cloud_sync.Args( + url="https://cloud.tm.center", + pack_folder=Path("/srv/mc/instance"), + token_file=Path("/srv/mc/instance/.cloud-sync/token"), + restic_binary=None, # auto-discover + allow_download=True, + headless=True, +)) ``` -Output: `build/libs/cloud-sync-.jar`. Single fat jar; ship as-is. +frazclient's `client.py` consumes this directly via `import cloud_sync` instead of subprocessing the pyz. -For local development with a matching JDK installed: +## On-disk layout -```bash -./gradlew shadowJar +Per-instance state under `/.cloud-sync/`: + +``` +.cloud-sync/ + token # discord_id:password (mode 0600) + scope.json # optional; defaults baked in if missing + restic- # auto-downloaded binary + files-from.txt # restic --files-from + exclude-from.txt # restic --exclude-from ``` -## UI +Auto-excluded from sync. Multiple MC instances = multiple `.cloud-sync/` dirs with independent credentials. -Swing + [FlatLaf](https://www.formdev.com/flatlaf/) (`FlatMacDarkLaf` theme). Closest visual match to Prism's Qt look in pure Java. Falls back to system look-and-feel if FlatLaf init fails (e.g., on bare-bones X servers). +## Why Python (not a JAR) -Conflict resolution dialog: per-file [keep local | use remote | skip] radio buttons + "use all local/remote" + Cancel/Continue. Headless mode (`-g`) defaults all conflicts to remote-wins (Steam's default). +1. **Antivirus.** Unsigned JARs that auto-download binaries + upload files are textbook Windows Defender false-positive triggers. Python invoked by code-signed `python.exe` mostly sidesteps that. +2. **Future Qt UI.** PySide6 opens a path to a real Qt UI (matching Prism's look) if richer surfaces are wanted later. JVM Qt bindings are abandoned. +3. **frazclient already needs Python.** Inlining as an import is zero overhead; the same package serves Prism via the pyz. -## Where this fits in the automc stack +Cost: players using Prism must have Python 3.10+ installed. Most Linux/Mac systems already do; Windows users install once from the Microsoft Store or python.org. -| Tool | What it owns | -|---|---| -| **packwiz-installer-bootstrap** | Mod sync — jars, configs shipped by the modpack, options.txt baseline | -| **cloud-sync** (this) | Per-user sync — player-modified configs, JourneyMap waypoints, screenshots | -| **frazclient** | Cracked-launcher orchestration — JDK, vanilla MC, Fabric, then invokes both jars | +## Where the data lives -cloud-sync and packwiz-installer-bootstrap are deliberately **separate jars** so players can disable cloud sync without affecting modpack sync (or vice versa) by just commenting out the line in Prism's hook config. +| Component | Role | Repo | +|---|---|---| +| `cloud-sync` (this) | Player-side. Subprocess restic for pull/push. | `Timemachine/cloud-sync` | +| `cloud-svc` | Operator-side control plane (provisioning + admin). | `Timemachine/cloud-svc` | +| `restic-rest-server` (existing) | Data plane. Player's restic hits it directly with their password. | upstream | +| `discord-bot` | Calls cloud-svc on `/register` to provision a player's cloud account. | `Timemachine/discord-bot` | ## License diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 9240a37..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,65 +0,0 @@ -plugins { - kotlin("jvm") version "2.1.0" - kotlin("plugin.serialization") version "2.1.0" - id("com.gradleup.shadow") version "8.3.5" - application -} - -group = "center.timemachine.cloud" -version = "0.1.0" - -repositories { - mavenCentral() -} - -dependencies { - // FlatLaf gives us an IntelliJ-style dark theme on Swing — - // closest visual match to Prism Launcher's Qt look in pure Java. - implementation("com.formdev:flatlaf:3.5.4") - implementation("com.formdev:flatlaf-intellij-themes:3.5.4") - - // JSON via Kotlin's official lib; supports kotlin data classes natively. - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - - // bz2 decompression of restic release archives without shelling out to - // system bzcat (covers stripped-down containers). - implementation("org.apache.commons:commons-compress:1.27.1") - - testImplementation(kotlin("test")) - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") -} - -// Target Java 17 bytecode using whatever JDK is installed locally (we -// require >= 17). Avoids the Gradle toolchain auto-provisioning dance, -// at the cost of needing a manual JDK 17+ on dev machines and CI runners. -kotlin { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - } -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -application { - mainClass.set("center.timemachine.cloud.MainKt") -} - -tasks.test { - useJUnitPlatform() -} - -tasks.shadowJar { - archiveBaseName.set("cloud-sync") - archiveClassifier.set("") - archiveVersion.set(project.version.toString()) - mergeServiceFiles() -} - -// Defer to shadowJar for the canonical artifact; the default 'jar' task -// produces a thin jar without dependencies which would be useless to ship. -tasks.jar { - enabled = false -} diff --git a/cloud_sync/__init__.py b/cloud_sync/__init__.py new file mode 100644 index 0000000..9df3c2b --- /dev/null +++ b/cloud_sync/__init__.py @@ -0,0 +1,14 @@ +"""cloud-sync — per-user state sync for Minecraft via restic. + +Public API for in-process callers (e.g. frazclient): + + import cloud_sync + cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance")) + cloud_sync.push(url="https://cloud.tm.center", pack_folder=Path("/instance")) +""" + +from .cli import Args +from .sync import pull, push + +__version__ = "0.1.0" +__all__ = ["Args", "pull", "push", "__version__"] diff --git a/cloud_sync/__main__.py b/cloud_sync/__main__.py new file mode 100644 index 0000000..c5531f0 --- /dev/null +++ b/cloud_sync/__main__.py @@ -0,0 +1,11 @@ +"""Entry for ``python -m cloud_sync`` and the zipapp build.""" + +from __future__ import annotations + +import sys + +from .cli import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cloud_sync/cli.py b/cloud_sync/cli.py new file mode 100644 index 0000000..8184337 --- /dev/null +++ b/cloud_sync/cli.py @@ -0,0 +1,102 @@ +"""CLI parsing + entry point dispatch. + +Flag style mirrors packwiz-installer-bootstrap so operators wiring Prism's +PreLaunch/PostExit hooks don't relearn the surface. Supports both +``--url value`` and ``--url=value`` forms. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Args: + """Parsed CLI args shared by both pull + push subcommands.""" + + url: str + pack_folder: Path + token_file: Path + restic_binary: Path | None # None → auto-discover + allow_download: bool + headless: bool + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="cloud-sync", + description="Per-user Minecraft state sync via restic.", + ) + p.add_argument("--version", action="version", version="cloud-sync 0.1.0") + + sub = p.add_subparsers(dest="cmd", required=True) + for name in ("pull", "push"): + sp = sub.add_parser(name, help=f"{name} player state") + sp.add_argument( + "--url", required=True, + help="cloud-svc data plane URL (e.g. https://cloud.tm.center)", + ) + sp.add_argument( + "--pack-folder", default=".", type=Path, + help="Minecraft instance directory (default: cwd)", + ) + sp.add_argument( + "--token-file", default=None, type=Path, + help="Token file path (default: /.cloud-sync/token)", + ) + sp.add_argument( + "--restic-binary", default=None, type=Path, + help="Path to a restic binary; overrides auto-discovery", + ) + sp.add_argument( + "--no-download", action="store_true", + help="Don't auto-fetch restic from upstream; fail if not found locally", + ) + sp.add_argument( + "-g", "--no-gui", action="store_true", + help="Headless mode (no Swing/Qt windows, restic stdout only)", + ) + return p + + +def parse(argv: list[str]) -> tuple[str, Args]: + """Parse argv → (subcommand, Args). Raises SystemExit on error/help.""" + ns = build_parser().parse_args(argv) + pack = Path(ns.pack_folder).absolute().resolve() + token = ( + Path(ns.token_file).absolute() + if ns.token_file is not None + else pack / ".cloud-sync" / "token" + ) + return ns.cmd, Args( + url=ns.url, + pack_folder=pack, + token_file=token, + restic_binary=Path(ns.restic_binary).absolute() if ns.restic_binary else None, + allow_download=not ns.no_download, + headless=ns.no_gui, + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error).""" + # Import here to keep CLI import light (test isolation). + from . import sync + + try: + cmd, args = parse(sys.argv[1:] if argv is None else argv) + except SystemExit as e: + return int(e.code) if isinstance(e.code, int) else 2 + + action = {"pull": sync.pull, "push": sync.push}[cmd] + try: + return action(args) + except KeyboardInterrupt: + print("cloud-sync: cancelled", file=sys.stderr) + return 1 + except Exception as e: # noqa: BLE001 + print(f"cloud-sync {cmd}: {e}", file=sys.stderr) + return 2 diff --git a/cloud_sync/creds.py b/cloud_sync/creds.py new file mode 100644 index 0000000..aaac857 --- /dev/null +++ b/cloud_sync/creds.py @@ -0,0 +1,40 @@ +"""Token file reader. + +Format: ``discord_id:password`` on a single line. Whitespace tolerated. +The Discord ID is the URL path segment under cloud.tm.center// that +restic-rest-server's --private-repos enforces against the basic-auth +username. The password is the bcrypt'd entry's plaintext AND the restic +repo encryption password (cloud-svc provisions one password covering both). +""" + +from __future__ import annotations + +from pathlib import Path + + +class CredentialsError(Exception): + """Raised when the token file is missing or malformed.""" + + +def read_credentials(token_file: Path) -> tuple[str, str]: + if not token_file.exists(): + raise CredentialsError( + f"cloud-sync token not found at {token_file}. " + f"After /register in Discord you should have received credentials; " + f"paste them into this file as 'discord_id:password' on one line." + ) + raw = token_file.read_text(encoding="utf-8").strip() + if ":" not in raw: + raise CredentialsError( + f"cloud-sync token at {token_file} malformed " + f"(expected 'discord_id:password' on one line)" + ) + discord_id, password = raw.split(":", 1) + discord_id = discord_id.strip() + password = password.strip() + if not discord_id or not password: + raise CredentialsError( + f"cloud-sync token at {token_file} malformed " + f"(empty discord_id or password)" + ) + return discord_id, password diff --git a/cloud_sync/restic.py b/cloud_sync/restic.py new file mode 100644 index 0000000..fb68880 --- /dev/null +++ b/cloud_sync/restic.py @@ -0,0 +1,223 @@ +"""restic binary discovery + auto-download + invocation. + +Discovery order: + 1. ``--restic-binary `` (explicit override) + 2. ``/.cloud-sync/restic-`` (pinned cached copy) + 3. ``$PATH`` (only if version matches RESTIC_VERSION) + 4. download from GitHub releases (unless ``--no-download``) + +The version is pinned because repos written by one restic version can have +features another version can't read. Cache the pinned binary per-instance +so deleting the instance dir wipes everything cloud-sync owns. +""" + +from __future__ import annotations + +import bz2 +import hashlib +import os +import platform +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path + +from .cli import Args + + +RESTIC_VERSION = "0.18.0" +RELEASE_TAG = f"v{RESTIC_VERSION}" + +_HTTP_TIMEOUT = 30 + + +@dataclass(frozen=True) +class Platform: + os_tag: str # linux, darwin, windows + arch_tag: str # amd64, arm64 + is_windows: bool + + +def _detect_platform() -> Platform: + name = platform.system().lower() + arch_raw = platform.machine().lower() + if name.startswith("linux"): + os_tag = "linux" + elif name.startswith("darwin"): + os_tag = "darwin" + elif name.startswith("windows"): + os_tag = "windows" + else: + raise RuntimeError(f"unsupported platform: {name}") + + if arch_raw in ("amd64", "x86_64"): + arch_tag = "amd64" + elif arch_raw in ("aarch64", "arm64"): + arch_tag = "arm64" + else: + raise RuntimeError(f"unsupported architecture: {arch_raw}") + return Platform(os_tag, arch_tag, os_tag == "windows") + + +def _binary_filename(plat: Platform) -> str: + suffix = ".exe" if plat.is_windows else "" + return f"restic-{RESTIC_VERSION}{suffix}" + + +def resolve_binary(args: Args) -> Path: + """Return a usable restic binary path, downloading if needed.""" + if args.restic_binary is not None: + if not args.restic_binary.exists(): + raise FileNotFoundError( + f"--restic-binary path does not exist: {args.restic_binary}" + ) + return args.restic_binary + + plat = _detect_platform() + cache_dir = args.pack_folder / ".cloud-sync" + cached = cache_dir / _binary_filename(plat) + if cached.exists() and os.access(cached, os.X_OK): + return cached + + # Try $PATH only when version matches exactly + system = _find_system_binary_matching_version() + if system is not None: + return system + + if not args.allow_download: + raise RuntimeError( + f"no usable restic binary at {cached} or on $PATH, " + f"and --no-download disabled auto-fetch" + ) + + cache_dir.mkdir(parents=True, exist_ok=True) + _download_restic_to(cached, plat) + return cached + + +def run( + binary: Path, + args: list[str], + env: dict[str, str] | None = None, + cwd: Path | None = None, + timeout: int = 900, +) -> tuple[int, str]: + """Run restic. Inherits stderr to caller's terminal for live progress. + Returns (returncode, captured_stdout).""" + merged_env = dict(os.environ) + if env: + merged_env.update(env) + p = subprocess.run( # noqa: S603 — controlled invocation + [str(binary), *args], + cwd=str(cwd) if cwd else None, + env=merged_env, + stdout=subprocess.PIPE, + stderr=sys.stderr, + text=True, + timeout=timeout, + check=False, + ) + return p.returncode, p.stdout + + +# --------------------------------------------------------------------------- +# discovery + download helpers +# --------------------------------------------------------------------------- + + +def _find_system_binary_matching_version() -> Path | None: + name = "restic.exe" if _detect_platform().is_windows else "restic" + found = shutil.which(name) + if not found: + return None + path = Path(found) + return path if _binary_version(path) == RESTIC_VERSION else None + + +def _binary_version(path: Path) -> str | None: + try: + out = subprocess.run( # noqa: S603 + [str(path), "version"], + capture_output=True, text=True, timeout=5, check=False, + ) + except (subprocess.SubprocessError, OSError): + return None + text = out.stdout + out.stderr + m = re.search(r"restic\s+(\d+\.\d+\.\d+)", text) + return m.group(1) if m else None + + +def _download_restic_to(target: Path, plat: Platform) -> None: + ext = "zip" if plat.is_windows else "bz2" + asset = f"restic_{RESTIC_VERSION}_{plat.os_tag}_{plat.arch_tag}.{ext}" + asset_url = ( + f"https://github.com/restic/restic/releases/download/" + f"{RELEASE_TAG}/{asset}" + ) + sums_url = ( + f"https://github.com/restic/restic/releases/download/" + f"{RELEASE_TAG}/SHA256SUMS" + ) + print( + f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}", + file=sys.stderr, + ) + with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + with urllib.request.urlopen(asset_url, timeout=_HTTP_TIMEOUT) as r: + tmp_path.write_bytes(r.read()) + expected = _expected_sha(sums_url, asset) + actual = _sha256_file(tmp_path) + if expected.lower() != actual.lower(): + raise RuntimeError( + f"restic download sha mismatch: expected {expected}, got {actual}" + ) + _decompress_into(tmp_path, target, ext) + if not plat.is_windows: + target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR) + finally: + tmp_path.unlink(missing_ok=True) + + +def _expected_sha(sums_url: str, asset: str) -> str: + with urllib.request.urlopen(sums_url, timeout=_HTTP_TIMEOUT) as r: + body = r.read().decode("utf-8") + for line in body.splitlines(): + line = line.strip() + if line.endswith(asset): + return line.split()[0] + raise RuntimeError(f"restic SHA256SUMS missing entry for {asset}") + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _decompress_into(archive: Path, target: Path, ext: str) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if ext == "bz2": + with bz2.open(archive, "rb") as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst) + elif ext == "zip": + with zipfile.ZipFile(archive) as zf: + inner = next( + (n for n in zf.namelist() if n.endswith("restic.exe")), + None, + ) + if inner is None: + raise RuntimeError("restic.exe not found in downloaded zip") + with zf.open(inner) as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst) + else: + raise RuntimeError(f"unsupported archive ext: {ext}") diff --git a/cloud_sync/scope.py b/cloud_sync/scope.py new file mode 100644 index 0000000..9255deb --- /dev/null +++ b/cloud_sync/scope.py @@ -0,0 +1,84 @@ +"""Per-distribution sync scope (include/exclude paths). + +Each cloud-sync deployment ships its own ``scope.json`` that picks which +files participate in sync. Lives at ``/.cloud-sync/scope.json``. +Defaults are baked in so a fresh install with no scope.json works. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +DEFAULT_INCLUDE: list[str] = [ + "options.txt", + "optionsof.txt", + "optionsshaders.txt", + "config/", + "journeymap/data/", + "screenshots/", +] + +DEFAULT_EXCLUDE: list[str] = [ + ".cloud-sync/", # never sync our own state dir + ".cloud-token", # legacy location (pre-jar/pre-restic era) + "config/simple-mod-sync*", + "config/packwiz*", + "**/cache/", + "**/*.log", + "**/*.tmp", +] + + +@dataclass(frozen=True) +class Scope: + include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE)) + exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE)) + + +def load(pack_folder: Path) -> Scope: + """Read scope.json or return defaults.""" + path = pack_folder / ".cloud-sync" / "scope.json" + if not path.exists(): + return Scope() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + print( + f"cloud-sync: scope.json invalid ({e}); using defaults", + file=sys.stderr, + ) + return Scope() + return Scope( + include=list(data.get("include", DEFAULT_INCLUDE)), + exclude=list(data.get("exclude", DEFAULT_EXCLUDE)), + ) + + +def materialize_for_restic(pack_folder: Path, scope: Scope) -> tuple[Path, Path]: + """Write files-from + exclude-from text files restic can consume. + + Files include directories; restic recurses into them. Exclude patterns + are matched against file paths during the walk. + """ + state_dir = pack_folder / ".cloud-sync" + state_dir.mkdir(parents=True, exist_ok=True) + files_from = state_dir / "files-from.txt" + exclude_from = state_dir / "exclude-from.txt" + + files_from.write_text( + "\n".join(_trim_trailing_slash(p) for p in scope.include) + "\n", + encoding="utf-8", + ) + exclude_from.write_text( + "\n".join(scope.exclude) + "\n", + encoding="utf-8", + ) + return files_from, exclude_from + + +def _trim_trailing_slash(s: str) -> str: + return s.rstrip("/") if s.endswith("/") else s diff --git a/cloud_sync/sync.py b/cloud_sync/sync.py new file mode 100644 index 0000000..f9f0bfe --- /dev/null +++ b/cloud_sync/sync.py @@ -0,0 +1,129 @@ +"""pull + push entry points. + +Both subprocess restic against ``rest:://:@//`` +where the same password is the HTTP basic credential and the repo +encryption key. cloud-svc provisions one password covering both. +""" + +from __future__ import annotations + +import sys +import urllib.parse +from pathlib import Path + +from . import restic, scope as scopemod +from .cli import Args +from .creds import read_credentials + + +def pull(args: Args) -> int: + """Restore latest snapshot's files into pack_folder. + + If the repo has no snapshots yet, this is a no-op (first run on this + machine; nothing to restore). + """ + discord_id, password = read_credentials(args.token_file) + binary = restic.resolve_binary(args) + repo = _restic_repo(args.url, discord_id, password) + env = _restic_env(password) + + # Check whether any snapshots exist + code, out = restic.run( + binary, + ["-r", repo, "snapshots", "--json", "--latest", "1"], + env=env, + ) + if code != 0: + print( + f"cloud-sync: failed to list snapshots (restic exit {code})", + file=sys.stderr, + ) + return 2 + stripped = out.strip() + if stripped in ("", "null", "[]"): + print( + "cloud-sync: no snapshots yet for this user " + "(first run on this machine?); nothing to pull" + ) + return 0 + + scope = scopemod.load(args.pack_folder) + _, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) + + code, _ = restic.run( + binary, + [ + "-r", repo, "restore", "latest", + "--target", str(args.pack_folder), + "--exclude-file", str(exclude_from), + ], + env=env, + ) + if code != 0: + print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr) + return 2 + print("cloud-sync: pull ok") + return 0 + + +def push(args: Args) -> int: + """Snapshot the in-scope files into the user's repo.""" + discord_id, password = read_credentials(args.token_file) + binary = restic.resolve_binary(args) + repo = _restic_repo(args.url, discord_id, password) + env = _restic_env(password) + + scope = scopemod.load(args.pack_folder) + files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) + + code, _ = restic.run( + binary, + [ + "-r", repo, "backup", + "--files-from", str(files_from), + "--exclude-file", str(exclude_from), + "--host", "cloud-sync", + "--tag", "auto", + ], + env=env, + cwd=args.pack_folder, + ) + if code != 0: + print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr) + return 2 + print("cloud-sync: push ok") + return 0 + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _restic_repo(base_url: str, discord_id: str, password: str) -> str: + """Build rest:://:@// + + URL-embedded basic auth is universally supported by restic; alternative + env vars (RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD) require 0.16+. + """ + raw = base_url.strip() + if raw.startswith("rest:"): + raw = raw[len("rest:"):] + raw = raw.rstrip("/") + scheme_end = raw.find("://") + if scheme_end <= 0: + raise ValueError( + f"--url must include scheme (http:// or https://): {base_url!r}" + ) + scheme = raw[: scheme_end + 3] + host_and_path = raw[scheme_end + 3 :] + u = urllib.parse.quote(discord_id, safe="") + p = urllib.parse.quote(password, safe="") + return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/" + + +def _restic_env(password: str) -> dict[str, str]: + return { + "RESTIC_PASSWORD": password, + "RESTIC_PROGRESS_FPS": "0", + } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index a4b76b9530d66f5e68d973ea569d8e19de379189..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 475cfde..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -networkTimeout=10000 -validateDistributionUrl=true diff --git a/gradlew b/gradlew deleted file mode 100755 index d95bf61..0000000 --- a/gradlew +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 640d686..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ee65729 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling>=1.21"] +build-backend = "hatchling.build" + +[project] +name = "cloud-sync" +version = "0.1.0" +description = "Per-user state sync for Minecraft via restic. Drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +authors = [{ name = "Timemachine", email = "ops@timemachine.center" }] +keywords = ["minecraft", "restic", "sync", "automc"] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Games/Entertainment", + "Topic :: System :: Archiving :: Backup", +] +dependencies = [] # stdlib only + +[project.optional-dependencies] +test = ["pytest>=8.0"] +# Future: pip install cloud-sync[qt] when we add a Qt progress / config UI +qt = ["PySide6>=6.7"] + +[project.scripts] +cloud-sync = "cloud_sync.cli:main" + +[project.urls] +Homepage = "https://git.timemachine.center/Timemachine/cloud-sync" +Issues = "https://git.timemachine.center/Timemachine/cloud-sync/issues" + +[tool.hatch.build.targets.wheel] +packages = ["cloud_sync"] diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 477bf8d..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "cloud-sync" diff --git a/src/main/kotlin/center/timemachine/cloud/Args.kt b/src/main/kotlin/center/timemachine/cloud/Args.kt deleted file mode 100644 index 3906ee1..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Args.kt +++ /dev/null @@ -1,98 +0,0 @@ -package center.timemachine.cloud - -import java.nio.file.Path -import java.nio.file.Paths - -/** - * Parsed command-line arguments shared by pull + push subcommands. - * - * Flag style matches packwiz-installer-bootstrap (`--url=` or `--url value`, - * `-g` shorthand) so operators wiring Prism's PreLaunch/PostExit hooks - * don't have to relearn the surface. - */ -data class Args( - val url: String, - val packFolder: Path, - val tokenFile: Path, - val resticBinary: Path?, // null = auto-discover - val allowDownload: Boolean, // false = --no-download - val headless: Boolean, -) - -/** - * Thrown when args fail validation. Message is shown to the user; - * the launcher caller sees a non-zero exit but the message is what - * tells the operator what to fix. - */ -class ArgParseException(message: String) : RuntimeException(message) - -/** - * Parse argv for a sync subcommand. Caller already stripped the - * subcommand name (pull/push) — `args` is everything after. - */ -fun parseArgs(args: Array): Args { - var url: String? = null - var packFolder: String? = null - var tokenFile: String? = null - var resticBinary: String? = null - var allowDownload = true - var headless = false - - val iter = args.iterator() - while (iter.hasNext()) { - val raw = iter.next() - val (flag, valueInline) = splitFlag(raw) - when (flag) { - "--url" -> url = valueInline ?: takeValue(iter, flag) - "--pack-folder" -> packFolder = valueInline ?: takeValue(iter, flag) - "--token-file" -> tokenFile = valueInline ?: takeValue(iter, flag) - "--restic-binary" -> resticBinary = valueInline ?: takeValue(iter, flag) - "--no-download" -> { - rejectInlineValue(flag, valueInline) - allowDownload = false - } - "-g", "--no-gui" -> { - rejectInlineValue(flag, valueInline) - headless = true - } - else -> throw ArgParseException("unknown flag: $raw") - } - } - - if (url.isNullOrBlank()) { - throw ArgParseException("--url is required (cloud-svc data plane URL, e.g. https://cloud.tm.center)") - } - - val packPath: Path = packFolder?.let { Paths.get(it) } ?: Paths.get(".") - val tokenPath: Path = tokenFile?.let { Paths.get(it) } - ?: packPath.resolve(".cloud-sync").resolve("token") - val resticPath: Path? = resticBinary?.let { Paths.get(it) } - - return Args( - url = url, - packFolder = packPath.toAbsolutePath().normalize(), - tokenFile = tokenPath, - resticBinary = resticPath, - allowDownload = allowDownload, - headless = headless, - ) -} - -/** Split "--flag=value" into ("--flag", "value"). Returns (raw, null) when no '=' present. */ -private fun splitFlag(raw: String): Pair { - val eq = raw.indexOf('=') - return if (eq == -1) raw to null else raw.substring(0, eq) to raw.substring(eq + 1) -} - -private fun takeValue(iter: Iterator, flag: String): String { - if (!iter.hasNext()) { - throw ArgParseException("$flag requires a value") - } - return iter.next() -} - -private fun rejectInlineValue(flag: String, value: String?) { - if (value != null) { - throw ArgParseException("$flag does not take a value") - } -} diff --git a/src/main/kotlin/center/timemachine/cloud/Cli.kt b/src/main/kotlin/center/timemachine/cloud/Cli.kt deleted file mode 100644 index 591766b..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Cli.kt +++ /dev/null @@ -1,26 +0,0 @@ -package center.timemachine.cloud - -/** - * Subcommand dispatchers. Real work happens in Sync.kt; this layer - * parses args, surfaces ArgParseException as a clean error, and - * funnels through to pull/push. - */ -object Cli { - fun runPull(args: Array): Int = run("pull", args, ::pull) - fun runPush(args: Array): Int = run("push", args, ::push) - - private fun run(name: String, raw: Array, action: (Args) -> Int): Int { - val parsed = try { - parseArgs(raw) - } catch (e: ArgParseException) { - System.err.println("cloud-sync $name: ${e.message}") - return 2 - } - return try { - action(parsed) - } catch (e: Exception) { - System.err.println("cloud-sync $name: ${e.message ?: e.toString()}") - 2 - } - } -} diff --git a/src/main/kotlin/center/timemachine/cloud/Main.kt b/src/main/kotlin/center/timemachine/cloud/Main.kt deleted file mode 100644 index d1c728d..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Main.kt +++ /dev/null @@ -1,74 +0,0 @@ -package center.timemachine.cloud - -import com.formdev.flatlaf.themes.FlatMacDarkLaf -import javax.swing.UIManager - -/** - * cloud-sync entrypoint. - * - * Invoked twice in the launcher hooks: - * PreLaunch: java -jar cloud-sync.jar pull --url=... --pack-folder=... - * PostExit: java -jar cloud-sync.jar push --url=... --pack-folder=... - * - * Exit codes: - * 0 ok - * 1 user cancelled - * 2 error - */ -fun main(args: Array) { - if (args.isEmpty() || args[0] in listOf("-h", "--help", "help")) { - printHelp() - return - } - if (args[0] in listOf("--version", "-V")) { - println("cloud-sync 0.1.0") - return - } - - // Install FlatLaf theme as early as possible so any Swing window - // we open later picks it up. Falls back to system L&F on failure. - try { - UIManager.setLookAndFeel(FlatMacDarkLaf()) - } catch (e: Exception) { - System.err.println("note: FlatLaf init failed (${e.message}); using system L&F") - } - - val subcommand = args[0] - val rest = args.drop(1).toTypedArray() - - val rc = when (subcommand) { - "pull" -> Cli.runPull(rest) - "push" -> Cli.runPush(rest) - else -> { - System.err.println("unknown subcommand: $subcommand") - printHelp() - 2 - } - } - kotlin.system.exitProcess(rc) -} - -private fun printHelp() { - println(""" - cloud-sync — per-user state sync for Minecraft clients - - Usage: - java -jar cloud-sync.jar [flags] - - Subcommands: - pull Fetch user's cloud state, apply conflict resolution, write to instance. - push Walk instance, build snapshot, upload changed files. - - Flags: - --url cloud-svc base URL (required) - --pack-folder instance dir to sync into/from (default: ".") - --token bearer token (or use --token-file) - --token-file read token from this file (default: /.cloud-token) - -g, --no-gui headless mode; conflicts auto-resolve to remote-wins - -V, --version print version - -h, --help print this help - - Environment: - CLOUD_TOKEN fallback token source if no flag and no file - """.trimIndent()) -} diff --git a/src/main/kotlin/center/timemachine/cloud/Restic.kt b/src/main/kotlin/center/timemachine/cloud/Restic.kt deleted file mode 100644 index 6393379..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Restic.kt +++ /dev/null @@ -1,246 +0,0 @@ -package center.timemachine.cloud - -import java.io.BufferedInputStream -import java.io.IOException -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE -import java.nio.file.attribute.PosixFilePermission.OWNER_READ -import java.nio.file.attribute.PosixFilePermission.OWNER_WRITE -import java.security.MessageDigest -import java.time.Duration -import java.util.concurrent.TimeUnit -import kotlin.io.path.exists -import kotlin.io.path.isExecutable - -/** - * restic binary discovery + auto-download + invocation. - * - * Discovery order: - * 1. --restic-binary (explicit override) - * 2. /.cloud-sync/restic- (pinned cached copy) - * 3. system `restic` on PATH (only if version matches RESTIC_VERSION) - * 4. download from GitHub releases (unless --no-download disables) - * - * Pinning the version simplifies cross-platform behaviour — repos written by - * one version may have features another version can't read. Cache the pinned - * binary per-instance so removing the instance dir removes everything. - */ -object Restic { - const val RESTIC_VERSION = "0.18.0" - - /** Tag of the GitHub release we download from. */ - private const val RELEASE_TAG = "v$RESTIC_VERSION" - - /** - * Resolve which restic binary to use, downloading if necessary. Throws - * IOException if discovery + download both fail. - */ - fun resolveBinary(args: Args): Path { - args.resticBinary?.let { p -> - require(p.exists()) { "--restic-binary path does not exist: $p" } - return p.toAbsolutePath() - } - - val cacheDir = args.packFolder.resolve(".cloud-sync") - val expectedName = if (isWindows()) "restic-$RESTIC_VERSION.exe" else "restic-$RESTIC_VERSION" - val cached = cacheDir.resolve(expectedName) - if (cached.exists() && cached.isExecutable()) { - return cached - } - - // Try $PATH only if its version matches the pinned one — different versions - // can produce incompatible repos, so we don't trust an "any restic on PATH" install. - findSystemBinaryMatchingVersion()?.let { return it } - - if (!args.allowDownload) { - throw IOException( - "no usable restic binary found at $cached or on \$PATH, " + - "and --no-download disabled the fetch from upstream" - ) - } - - Files.createDirectories(cacheDir) - downloadResticTo(cached) - return cached - } - - /** - * Run restic with the given args + env. Inherits stderr to the caller's - * terminal for live progress. Returns (exitCode, stdout). - */ - fun run( - binary: Path, - args: List, - env: Map, - cwd: Path? = null, - ): Pair { - val pb = ProcessBuilder(listOf(binary.toString()) + args) - pb.redirectError(ProcessBuilder.Redirect.INHERIT) - cwd?.let { pb.directory(it.toFile()) } - // Carry over caller env; overlay our values - pb.environment().putAll(env) - val p = pb.start() - val out = p.inputStream.bufferedReader().use { it.readText() } - val ok = p.waitFor(15, TimeUnit.MINUTES) - if (!ok) { - p.destroyForcibly() - throw IOException("restic timed out after 15 minutes (cmd: ${args.joinToString(" ")})") - } - return p.exitValue() to out - } - - // ----------------------------------------------------------------- internals - - private fun isWindows() = System.getProperty("os.name").lowercase().contains("windows") - - private fun osTag() = when { - isWindows() -> "windows" - System.getProperty("os.name").lowercase().contains("mac") -> "darwin" - else -> "linux" - } - - private fun archTag() = when (val a = System.getProperty("os.arch").lowercase()) { - "amd64", "x86_64" -> "amd64" - "aarch64", "arm64" -> "arm64" - else -> error("unsupported architecture for restic auto-download: $a") - } - - private fun findSystemBinaryMatchingVersion(): Path? { - val pathEnv = System.getenv("PATH") ?: return null - val name = if (isWindows()) "restic.exe" else "restic" - for (dir in pathEnv.split(System.getProperty("path.separator"))) { - val cand = Path.of(dir, name) - if (cand.exists() && cand.isExecutable()) { - if (resticVersion(cand) == RESTIC_VERSION) { - return cand.toAbsolutePath() - } - } - } - return null - } - - private fun resticVersion(binary: Path): String? { - return try { - val p = ProcessBuilder(binary.toString(), "version").redirectErrorStream(true).start() - val out = p.inputStream.bufferedReader().use { it.readText() } - p.waitFor(5, TimeUnit.SECONDS) - // Output line: "restic 0.18.0 compiled with go..." - val regex = Regex("""restic\s+(\d+\.\d+\.\d+)""") - regex.find(out)?.groupValues?.get(1) - } catch (_: Exception) { - null - } - } - - private fun downloadResticTo(target: Path) { - val ext = if (osTag() == "windows") "zip" else "bz2" - val asset = "restic_${RESTIC_VERSION}_${osTag()}_${archTag()}.$ext" - val url = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/$asset" - System.err.println("cloud-sync: downloading restic $RESTIC_VERSION from $url") - - val client = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(15)) - .build() - val tmp = Files.createTempFile("restic-dl-", ".$ext") - try { - val req = HttpRequest.newBuilder(URI.create(url)).GET().build() - val resp = client.send(req, HttpResponse.BodyHandlers.ofFile(tmp)) - if (resp.statusCode() != 200) { - throw IOException("restic download failed: HTTP ${resp.statusCode()} for $url") - } - // Also fetch SHA256SUMS file to verify integrity - val sumsUrl = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/SHA256SUMS" - val sumsReq = HttpRequest.newBuilder(URI.create(sumsUrl)).GET().build() - val sumsBody = client.send(sumsReq, HttpResponse.BodyHandlers.ofString()).body() - val expectedSha = sumsBody.lineSequence() - .map { it.trim() } - .firstOrNull { it.endsWith(asset) } - ?.split(Regex("""\s+""")) - ?.firstOrNull() - ?: throw IOException("restic SHA256SUMS missing entry for $asset") - val actualSha = sha256OfFile(tmp) - if (actualSha != expectedSha.lowercase()) { - throw IOException("restic download sha mismatch: expected $expectedSha, got $actualSha") - } - decompressInto(tmp, target, ext) - makeExecutable(target) - } finally { - Files.deleteIfExists(tmp) - } - } - - private fun sha256OfFile(path: Path): String { - val md = MessageDigest.getInstance("SHA-256") - BufferedInputStream(Files.newInputStream(path)).use { input -> - val buf = ByteArray(64 * 1024) - while (true) { - val n = input.read(buf) - if (n <= 0) break - md.update(buf, 0, n) - } - } - return md.digest().joinToString("") { "%02x".format(it) } - } - - private fun decompressInto(archive: Path, target: Path, ext: String) { - Files.createDirectories(target.parent) - when (ext) { - "bz2" -> decompressBzip2(archive, target) - "zip" -> decompressZip(archive, target) - else -> error("unsupported archive ext: $ext") - } - } - - /** Minimal bz2 → file decoder using Apache Commons Compress's algorithm - * via JDK's built-in classes? No such thing exists in JDK; we shell out - * to bzcat instead, which is universally available on Linux/macOS. */ - private fun decompressBzip2(archive: Path, target: Path) { - // Try Java decoder if Commons Compress shaded in; otherwise fall back. - try { - val cls = Class.forName("org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream") - val ctor = cls.getConstructor(java.io.InputStream::class.java) - Files.newInputStream(archive).use { src -> - val bzin = ctor.newInstance(src) as java.io.InputStream - Files.newOutputStream(target).use { out -> bzin.copyTo(out) } - } - return - } catch (_: ClassNotFoundException) { - // continue to fallback - } - val p = ProcessBuilder("bzcat", archive.toString()).redirectError(ProcessBuilder.Redirect.INHERIT).start() - Files.newOutputStream(target).use { out -> p.inputStream.copyTo(out) } - if (!p.waitFor(60, TimeUnit.SECONDS) || p.exitValue() != 0) { - throw IOException("bzcat failed to decompress restic; install commons-compress or bzip2") - } - } - - private fun decompressZip(archive: Path, target: Path) { - java.util.zip.ZipInputStream(Files.newInputStream(archive)).use { zin -> - var e = zin.nextEntry - while (e != null) { - if (!e.isDirectory && e.name.endsWith("restic.exe")) { - Files.newOutputStream(target).use { out -> zin.copyTo(out) } - return - } - e = zin.nextEntry - } - } - throw IOException("restic.exe not found in downloaded zip") - } - - private fun makeExecutable(path: Path) { - if (isWindows()) return - try { - val perms = setOf(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE) - Files.setPosixFilePermissions(path, perms) - } catch (_: UnsupportedOperationException) { - // non-POSIX FS — best effort - } - } -} diff --git a/src/main/kotlin/center/timemachine/cloud/Scope.kt b/src/main/kotlin/center/timemachine/cloud/Scope.kt deleted file mode 100644 index bfd1519..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Scope.kt +++ /dev/null @@ -1,80 +0,0 @@ -package center.timemachine.cloud - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.exists - -/** - * Per-distribution scope file. Each cloud-sync.jar deployment ships its - * own scope.json that picks which files participate in sync. Path under - * /.cloud-sync/scope.json. - * - * Defaults are baked in so a fresh install with no scope.json still does - * something sensible. - */ -@Serializable -data class Scope( - val include: List = DEFAULT_INCLUDE, - val exclude: List = DEFAULT_EXCLUDE, -) - -private val DEFAULT_INCLUDE = listOf( - "options.txt", - "optionsof.txt", - "optionsshaders.txt", - "config/", - "journeymap/data/", - "screenshots/", -) - -private val DEFAULT_EXCLUDE = listOf( - ".cloud-sync/", // never sync our own state dir - ".cloud-token", // legacy location (frazclient pre-jar era) - "config/simple-mod-sync*", - "config/packwiz*", - "**/cache/", - "**/*.log", - "**/*.tmp", -) - -object Scope_ { - private val json = Json { ignoreUnknownKeys = true; prettyPrint = true } - - fun load(packFolder: Path): Scope { - val path = packFolder.resolve(".cloud-sync").resolve("scope.json") - if (!path.exists()) return Scope() - return try { - json.decodeFromString(Scope.serializer(), Files.readString(path)) - } catch (e: Exception) { - System.err.println("cloud-sync: invalid scope.json (${e.message}); using defaults") - Scope() - } - } - - /** - * Write absolute paths of include + a restic --exclude-file alongside. - * restic's --files-from accepts ABSOLUTE OR relative paths but exclude - * patterns are matched against the file path being processed. Including - * directories in --files-from causes restic to recurse into them - * automatically. - */ - fun materializeForRestic(packFolder: Path, scope: Scope): Pair { - val dir = packFolder.resolve(".cloud-sync") - Files.createDirectories(dir) - val filesFrom = dir.resolve("files-from.txt") - val excludeFrom = dir.resolve("exclude-from.txt") - Files.writeString( - filesFrom, - scope.include.joinToString(System.lineSeparator()) { trimTrailingSlash(it) }, - ) - Files.writeString( - excludeFrom, - scope.exclude.joinToString(System.lineSeparator()), - ) - return filesFrom to excludeFrom - } - - private fun trimTrailingSlash(s: String) = if (s.endsWith("/")) s.dropLast(1) else s -} diff --git a/src/main/kotlin/center/timemachine/cloud/Sync.kt b/src/main/kotlin/center/timemachine/cloud/Sync.kt deleted file mode 100644 index bd425d3..0000000 --- a/src/main/kotlin/center/timemachine/cloud/Sync.kt +++ /dev/null @@ -1,145 +0,0 @@ -package center.timemachine.cloud - -import java.io.IOException -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.exists - -/** - * Read token-file. Format: "discord_id:password" on a single line. Whitespace - * tolerated. Returns (discordId, password). Throws if missing or malformed. - * - * The Discord ID is the URL path segment under cloud.tm.center// that - * restic-rest-server's --private-repos enforces against the basic-auth - * username. The password is the bcrypt'd entry's plaintext. - */ -fun readCredentials(tokenFile: Path): Pair { - if (!tokenFile.exists()) { - throw IOException( - "cloud-sync token not found at $tokenFile. " + - "After /register in Discord you should have received credentials; " + - "paste them into this file as 'discord_id:password' on one line." - ) - } - val raw = Files.readString(tokenFile).trim() - val parts = raw.split(":", limit = 2) - if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) { - throw IOException("cloud-sync token at $tokenFile malformed (expected 'discord_id:password')") - } - return parts[0].trim() to parts[1].trim() -} - -/** - * Build the restic --repo URL: rest:://:@// - * - * URL-embedded basic auth is universally supported by restic; the alternative - * (RESTIC_REST_USERNAME / RESTIC_REST_PASSWORD env vars) requires restic 0.16+. - * Same password is used for HTTP basic auth and restic repo encryption — - * cloud-svc provisions one password per user covering both. - */ -private fun resticRepo(baseUrl: String, discordId: String, password: String): String { - val raw = baseUrl.trimStart().removePrefix("rest:").trimEnd('/') - val schemeEnd = raw.indexOf("://") - require(schemeEnd > 0) { "--url must include a scheme (http:// or https://): $baseUrl" } - val scheme = raw.substring(0, schemeEnd + 3) - val hostAndPath = raw.substring(schemeEnd + 3) - // URL-encode credentials to handle special chars in the password - val u = java.net.URLEncoder.encode(discordId, Charsets.UTF_8) - val p = java.net.URLEncoder.encode(password, Charsets.UTF_8) - return "rest:$scheme$u:$p@$hostAndPath/$discordId/" -} - -/** Common env applied to every restic invocation. */ -private fun resticEnv(password: String): Map = mapOf( - "RESTIC_PASSWORD" to password, - // Defensive: ensure restic doesn't try to be interactive about user prompts. - "RESTIC_PROGRESS_FPS" to "0", -) - -/** - * pull: restore latest snapshot's tracked files into . - * - * If the repo has no snapshots yet, this is a no-op (the user has never - * pushed; nothing to restore). We detect that via `restic snapshots --json` - * before attempting restore — restore on an empty repo errors out. - */ -fun pull(args: Args): Int { - val (discordId, password) = readCredentials(args.tokenFile) - val binary = Restic.resolveBinary(args) - val repo = resticRepo(args.url, discordId, password) - val env = resticEnv(password) - - // Check whether any snapshots exist - val (snapCode, snapOut) = Restic.run( - binary, - listOf("-r", repo, "snapshots", "--json", "--latest", "1"), - env, - ) - if (snapCode != 0) { - System.err.println("cloud-sync: failed to list snapshots (restic exit $snapCode)") - return 2 - } - // restic returns "null" or "[]" when repo is empty - val empty = snapOut.trim().let { it.isEmpty() || it == "null" || it == "[]" } - if (empty) { - println("cloud-sync: no snapshots yet for this user (first run on this machine?); nothing to pull") - return 0 - } - - val scope = Scope_.load(args.packFolder) - val (_, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope) - - // Restore overwrites files. Use --include for path filter (paths inside - // the snapshot are absolute as they were on the backup machine). We - // restore EVERYTHING in the snapshot to ; the snapshot was - // built from + scope, so all paths come back as expected. - val (rcCode, _) = Restic.run( - binary, - listOf( - "-r", repo, - "restore", "latest", - "--target", args.packFolder.toString(), - "--exclude-file", excludeFrom.toString(), - ), - env, - ) - if (rcCode != 0) { - System.err.println("cloud-sync: restic restore failed with exit $rcCode") - return 2 - } - println("cloud-sync: pull ok") - return 0 -} - -/** - * push: snapshot the in-scope files into the user's repo. - */ -fun push(args: Args): Int { - val (discordId, password) = readCredentials(args.tokenFile) - val binary = Restic.resolveBinary(args) - val repo = resticRepo(args.url, discordId, password) - val env = resticEnv(password) - - val scope = Scope_.load(args.packFolder) - val (filesFrom, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope) - - val (rcCode, _) = Restic.run( - binary, - listOf( - "-r", repo, - "backup", - "--files-from", filesFrom.toString(), - "--exclude-file", excludeFrom.toString(), - "--host", "cloud-sync", - "--tag", "auto", - ), - env, - cwd = args.packFolder, - ) - if (rcCode != 0) { - System.err.println("cloud-sync: restic backup failed with exit $rcCode") - return 2 - } - println("cloud-sync: push ok") - return 0 -} diff --git a/src/test/kotlin/center/timemachine/cloud/ArgsTest.kt b/src/test/kotlin/center/timemachine/cloud/ArgsTest.kt deleted file mode 100644 index 2d33d71..0000000 --- a/src/test/kotlin/center/timemachine/cloud/ArgsTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package center.timemachine.cloud - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ArgsTest { - @Test - fun `parses required url and applies defaults`() { - val a = parseArgs(arrayOf("--url=https://cloud.tm.center")) - assertEquals("https://cloud.tm.center", a.url) - assertTrue(a.allowDownload) - assertFalse(a.headless) - // default token file lives under /.cloud-sync/token - assertTrue(a.tokenFile.endsWith(".cloud-sync/token")) - } - - @Test - fun `url required`() { - val ex = assertFailsWith { parseArgs(emptyArray()) } - assertTrue(ex.message!!.contains("--url")) - } - - @Test - fun `space-separated values work`() { - val a = parseArgs(arrayOf("--url", "https://x", "--pack-folder", "/srv/mc")) - assertEquals("https://x", a.url) - assertEquals("/srv/mc", a.packFolder.toString()) - } - - @Test - fun `inline values work`() { - val a = parseArgs(arrayOf("--url=https://x", "--pack-folder=/srv/mc")) - assertEquals("https://x", a.url) - assertEquals("/srv/mc", a.packFolder.toString()) - } - - @Test - fun `no-gui flag`() { - val a = parseArgs(arrayOf("--url=https://x", "-g")) - assertTrue(a.headless) - val b = parseArgs(arrayOf("--url=https://x", "--no-gui")) - assertTrue(b.headless) - } - - @Test - fun `no-download flag disables fetch`() { - val a = parseArgs(arrayOf("--url=https://x", "--no-download")) - assertFalse(a.allowDownload) - } - - @Test - fun `unknown flag rejected`() { - val ex = assertFailsWith { - parseArgs(arrayOf("--url=https://x", "--bogus=foo")) - } - assertTrue(ex.message!!.contains("--bogus")) - } - - @Test - fun `bool flag with inline value rejected`() { - val ex = assertFailsWith { - parseArgs(arrayOf("--url=https://x", "--no-download=yes")) - } - assertTrue(ex.message!!.contains("does not take a value")) - } - - @Test - fun `missing value for non-bool flag rejected`() { - val ex = assertFailsWith { - parseArgs(arrayOf("--url=https://x", "--pack-folder")) - } - assertTrue(ex.message!!.contains("requires a value")) - } - - @Test - fun `custom token-file overrides default`() { - val a = parseArgs(arrayOf( - "--url=https://x", - "--pack-folder=/srv/mc", - "--token-file=/etc/cloud-creds", - )) - assertEquals("/etc/cloud-creds", a.tokenFile.toString()) - } - - @Test - fun `restic-binary override accepted`() { - val a = parseArgs(arrayOf("--url=https://x", "--restic-binary=/opt/restic")) - assertEquals("/opt/restic", a.resticBinary!!.toString()) - } -} diff --git a/src/test/kotlin/center/timemachine/cloud/CredentialsTest.kt b/src/test/kotlin/center/timemachine/cloud/CredentialsTest.kt deleted file mode 100644 index 2958379..0000000 --- a/src/test/kotlin/center/timemachine/cloud/CredentialsTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package center.timemachine.cloud - -import java.io.IOException -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -class CredentialsTest { - @Test - fun `parses discord_id and password from one-liner`() { - val tmp = Files.createTempFile("token-", "") - Files.writeString(tmp, "358881557521498112:s3cret-pass\n") - val (id, pw) = readCredentials(tmp) - assertEquals("358881557521498112", id) - assertEquals("s3cret-pass", pw) - } - - @Test - fun `trims whitespace around components`() { - val tmp = Files.createTempFile("token-", "") - Files.writeString(tmp, " 123 : pw \n") - val (id, pw) = readCredentials(tmp) - assertEquals("123", id) - assertEquals("pw", pw) - } - - @Test - fun `missing file raises actionable error`() { - val missing = Files.createTempDirectory("nothing-").resolve("missing-token") - val ex = assertFailsWith { readCredentials(missing) } - assertTrue(ex.message!!.contains("token not found")) - assertTrue(ex.message!!.contains("discord_id:password")) - } - - @Test - fun `missing colon rejected`() { - val tmp = Files.createTempFile("token-", "") - Files.writeString(tmp, "no-colon-here") - val ex = assertFailsWith { readCredentials(tmp) } - assertTrue(ex.message!!.contains("malformed")) - } - - @Test - fun `empty id rejected`() { - val tmp = Files.createTempFile("token-", "") - Files.writeString(tmp, ":password") - val ex = assertFailsWith { readCredentials(tmp) } - assertTrue(ex.message!!.contains("malformed")) - } -} diff --git a/src/test/kotlin/center/timemachine/cloud/SmokeTest.kt b/src/test/kotlin/center/timemachine/cloud/SmokeTest.kt deleted file mode 100644 index e763798..0000000 --- a/src/test/kotlin/center/timemachine/cloud/SmokeTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package center.timemachine.cloud - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SmokeTest { - @Test - fun `cli pull missing url returns 2`() { - assertEquals(2, Cli.runPull(emptyArray())) - } - - @Test - fun `cli push missing url returns 2`() { - assertEquals(2, Cli.runPush(emptyArray())) - } - - @Test - fun `cli pull unknown flag returns 2`() { - assertEquals(2, Cli.runPull(arrayOf("--url=https://x", "--bogus"))) - } -} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e018601 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,75 @@ +"""CLI parsing tests — argv → (subcommand, Args).""" + +from __future__ import annotations + +import pytest + +from cloud_sync.cli import Args, parse + + +def test_parses_pull_with_required_url() -> None: + cmd, args = parse(["pull", "--url=https://cloud.tm.center"]) + assert cmd == "pull" + assert isinstance(args, Args) + assert args.url == "https://cloud.tm.center" + assert args.allow_download is True + assert args.headless is False + + +def test_default_token_file_under_pack_folder() -> None: + _, args = parse(["pull", "--url=https://x", "--pack-folder=/tmp/inst"]) + assert args.token_file.as_posix().endswith(".cloud-sync/token") + assert "/tmp/inst" in args.token_file.as_posix() + + +def test_custom_token_file_overrides_default() -> None: + _, args = parse( + ["pull", "--url=https://x", "--pack-folder=/tmp/inst", + "--token-file=/etc/cloud-creds"] + ) + assert args.token_file.as_posix() == "/etc/cloud-creds" + + +def test_inline_and_space_separated_both_work() -> None: + _, a1 = parse(["pull", "--url=https://x", "--pack-folder=/srv"]) + _, a2 = parse(["pull", "--url", "https://x", "--pack-folder", "/srv"]) + assert a1.url == a2.url + assert a1.pack_folder == a2.pack_folder + + +def test_no_gui_flag() -> None: + _, a = parse(["push", "--url=https://x", "-g"]) + assert a.headless is True + _, b = parse(["push", "--url=https://x", "--no-gui"]) + assert b.headless is True + + +def test_no_download_flag() -> None: + _, a = parse(["push", "--url=https://x", "--no-download"]) + assert a.allow_download is False + + +def test_restic_binary_override() -> None: + _, a = parse(["push", "--url=https://x", "--restic-binary=/opt/restic"]) + assert a.restic_binary is not None + assert a.restic_binary.as_posix() == "/opt/restic" + + +def test_missing_url_exits() -> None: + with pytest.raises(SystemExit): + parse(["pull"]) + + +def test_missing_subcommand_exits() -> None: + with pytest.raises(SystemExit): + parse([]) + + +def test_unknown_subcommand_exits() -> None: + with pytest.raises(SystemExit): + parse(["bogus", "--url=https://x"]) + + +def test_pack_folder_is_resolved_to_absolute() -> None: + _, a = parse(["pull", "--url=https://x", "--pack-folder=."]) + assert a.pack_folder.is_absolute() diff --git a/tests/test_creds.py b/tests/test_creds.py new file mode 100644 index 0000000..73a46b3 --- /dev/null +++ b/tests/test_creds.py @@ -0,0 +1,62 @@ +"""Token file parser tests.""" + +from __future__ import annotations + +import pytest + +from cloud_sync.creds import CredentialsError, read_credentials + + +def test_parses_one_liner(tmp_path): + t = tmp_path / "token" + t.write_text("358881557521498112:s3cret-pass\n") + discord_id, password = read_credentials(t) + assert discord_id == "358881557521498112" + assert password == "s3cret-pass" + + +def test_trims_whitespace(tmp_path): + t = tmp_path / "token" + t.write_text(" 123 : pw \n") + discord_id, password = read_credentials(t) + assert discord_id == "123" + assert password == "pw" + + +def test_missing_file_raises_with_actionable_message(tmp_path): + missing = tmp_path / "missing-token" + with pytest.raises(CredentialsError) as exc: + read_credentials(missing) + assert "token not found" in str(exc.value) + assert "discord_id:password" in str(exc.value) + + +def test_missing_colon_rejected(tmp_path): + t = tmp_path / "token" + t.write_text("no-colon-here") + with pytest.raises(CredentialsError) as exc: + read_credentials(t) + assert "malformed" in str(exc.value) + + +def test_empty_id_rejected(tmp_path): + t = tmp_path / "token" + t.write_text(":password") + with pytest.raises(CredentialsError): + read_credentials(t) + + +def test_empty_password_rejected(tmp_path): + t = tmp_path / "token" + t.write_text("123:") + with pytest.raises(CredentialsError): + read_credentials(t) + + +def test_password_with_colon_kept_intact(tmp_path): + """Passwords containing : should be kept whole after the first split.""" + t = tmp_path / "token" + t.write_text("123:pw:with:colons") + discord_id, password = read_credentials(t) + assert discord_id == "123" + assert password == "pw:with:colons" diff --git a/tests/test_repo_url.py b/tests/test_repo_url.py new file mode 100644 index 0000000..e038849 --- /dev/null +++ b/tests/test_repo_url.py @@ -0,0 +1,54 @@ +"""restic repo URL builder + env tests.""" + +from __future__ import annotations + +import pytest + +from cloud_sync.sync import _restic_env, _restic_repo + + +def test_basic_http_url(): + repo = _restic_repo("http://cloud.tm.center", "12345", "secretpw") + assert repo == "rest:http://12345:secretpw@cloud.tm.center/12345/" + + +def test_https_url(): + repo = _restic_repo("https://cloud.tm.center", "12345", "pw") + assert repo == "rest:https://12345:pw@cloud.tm.center/12345/" + + +def test_trailing_slash_stripped(): + repo = _restic_repo("https://cloud.tm.center/", "12345", "pw") + assert repo == "rest:https://12345:pw@cloud.tm.center/12345/" + + +def test_url_with_port(): + repo = _restic_repo("http://127.0.0.1:8002", "alice", "pw") + assert repo == "rest:http://alice:pw@127.0.0.1:8002/alice/" + + +def test_rest_prefix_stripped_if_supplied(): + repo = _restic_repo("rest:http://x.test", "u", "p") + assert repo == "rest:http://u:p@x.test/u/" + + +def test_password_with_special_chars_encoded(): + repo = _restic_repo("http://x.test", "u", "p@ss/word?!&") + # URL-encoded form of "p@ss/word?!&" + assert "p%40ss%2Fword%3F%21%26@x.test" in repo + + +def test_user_with_special_chars_encoded(): + repo = _restic_repo("http://x.test", "u/with@chars", "pw") + assert "u%2Fwith%40chars" in repo + + +def test_missing_scheme_rejected(): + with pytest.raises(ValueError): + _restic_repo("cloud.tm.center", "u", "p") + + +def test_env_contains_password(): + env = _restic_env("hunter2") + assert env["RESTIC_PASSWORD"] == "hunter2" + assert "RESTIC_PROGRESS_FPS" in env diff --git a/tests/test_scope.py b/tests/test_scope.py new file mode 100644 index 0000000..8c5a33d --- /dev/null +++ b/tests/test_scope.py @@ -0,0 +1,70 @@ +"""Scope file reader + materializer tests.""" + +from __future__ import annotations + +import json + +from cloud_sync.scope import ( + DEFAULT_EXCLUDE, + DEFAULT_INCLUDE, + Scope, + load, + materialize_for_restic, +) + + +def test_load_missing_returns_defaults(tmp_path): + s = load(tmp_path) + assert s.include == DEFAULT_INCLUDE + assert s.exclude == DEFAULT_EXCLUDE + + +def test_load_valid_overrides_defaults(tmp_path): + state = tmp_path / ".cloud-sync" + state.mkdir() + (state / "scope.json").write_text(json.dumps({ + "include": ["foo/", "bar.txt"], + "exclude": ["**/*.log"], + })) + s = load(tmp_path) + assert s.include == ["foo/", "bar.txt"] + assert s.exclude == ["**/*.log"] + + +def test_load_partial_keeps_defaults_for_missing(tmp_path): + state = tmp_path / ".cloud-sync" + state.mkdir() + (state / "scope.json").write_text(json.dumps({"include": ["just-this"]})) + s = load(tmp_path) + assert s.include == ["just-this"] + assert s.exclude == DEFAULT_EXCLUDE + + +def test_load_invalid_falls_back(tmp_path, capsys): + state = tmp_path / ".cloud-sync" + state.mkdir() + (state / "scope.json").write_text("{not valid json") + s = load(tmp_path) + assert s.include == DEFAULT_INCLUDE + captured = capsys.readouterr() + assert "invalid" in captured.err.lower() + + +def test_materialize_writes_files(tmp_path): + scope = Scope(include=["config/", "options.txt"], exclude=["**/*.log"]) + files_from, exclude_from = materialize_for_restic(tmp_path, scope) + assert files_from.exists() + assert exclude_from.exists() + body_in = files_from.read_text().splitlines() + body_ex = exclude_from.read_text().splitlines() + # trailing slash stripped on include entries + assert "config" in body_in + assert "options.txt" in body_in + assert "**/*.log" in body_ex + + +def test_materialize_creates_state_dir(tmp_path): + scope = Scope(include=["x"], exclude=["y"]) + files_from, _ = materialize_for_restic(tmp_path, scope) + assert files_from.parent.name == ".cloud-sync" + assert files_from.parent.exists()