diff --git a/build.gradle.kts b/build.gradle.kts index 9a376fb..9240a37 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,10 @@ dependencies { // 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") } diff --git a/src/main/kotlin/center/timemachine/cloud/Args.kt b/src/main/kotlin/center/timemachine/cloud/Args.kt new file mode 100644 index 0000000..3906ee1 --- /dev/null +++ b/src/main/kotlin/center/timemachine/cloud/Args.kt @@ -0,0 +1,98 @@ +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 index d23547e..591766b 100644 --- a/src/main/kotlin/center/timemachine/cloud/Cli.kt +++ b/src/main/kotlin/center/timemachine/cloud/Cli.kt @@ -1,20 +1,26 @@ package center.timemachine.cloud /** - * Subcommand dispatchers. Skeleton — real arg parsing + sync logic lands - * in subsequent tasks. For now these print intent and exit cleanly so the - * fat jar can be smoke-tested end-to-end through Prism / frazclient. + * 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 { - println("cloud-sync pull: ${args.joinToString(" ")}") - println("(not yet implemented; skeleton commit only)") - return 0 - } + fun runPull(args: Array): Int = run("pull", args, ::pull) + fun runPush(args: Array): Int = run("push", args, ::push) - fun runPush(args: Array): Int { - println("cloud-sync push: ${args.joinToString(" ")}") - println("(not yet implemented; skeleton commit only)") - return 0 + 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/Restic.kt b/src/main/kotlin/center/timemachine/cloud/Restic.kt new file mode 100644 index 0000000..6393379 --- /dev/null +++ b/src/main/kotlin/center/timemachine/cloud/Restic.kt @@ -0,0 +1,246 @@ +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 new file mode 100644 index 0000000..bfd1519 --- /dev/null +++ b/src/main/kotlin/center/timemachine/cloud/Scope.kt @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..cb3c33d --- /dev/null +++ b/src/main/kotlin/center/timemachine/cloud/Sync.kt @@ -0,0 +1,131 @@ +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:// */ +private fun resticRepo(baseUrl: String, discordId: String): String { + val base = baseUrl.trimEnd('/').let { if (it.startsWith("rest:")) it else "rest:$it" } + return "$base/$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) + 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) + 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 new file mode 100644 index 0000000..2d33d71 --- /dev/null +++ b/src/test/kotlin/center/timemachine/cloud/ArgsTest.kt @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..2958379 --- /dev/null +++ b/src/test/kotlin/center/timemachine/cloud/CredentialsTest.kt @@ -0,0 +1,52 @@ +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 index 6fbb2df..e763798 100644 --- a/src/test/kotlin/center/timemachine/cloud/SmokeTest.kt +++ b/src/test/kotlin/center/timemachine/cloud/SmokeTest.kt @@ -5,12 +5,17 @@ import kotlin.test.assertEquals class SmokeTest { @Test - fun `cli pull returns 0 in skeleton`() { - assertEquals(0, Cli.runPull(arrayOf("--url=http://localhost:9091"))) + fun `cli pull missing url returns 2`() { + assertEquals(2, Cli.runPull(emptyArray())) } @Test - fun `cli push returns 0 in skeleton`() { - assertEquals(0, Cli.runPush(arrayOf("--url=http://localhost:9091"))) + 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"))) } }