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 a4b76b9..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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()