feat: CLI parser, restic discovery+download, pull/push via restic CLI
- Args.kt: parses --url, --pack-folder, --token-file, --restic-binary,
--no-download, -g/--no-gui. Inline and space-separated value forms.
- Restic.kt: locates restic via 1) --restic-binary override, 2) cached
<pack-folder>/.cloud-sync/restic-<ver>, 3) system PATH (version match),
4) auto-download from github releases + sha256 verify against SHA256SUMS.
bz2 decompression via commons-compress (bzcat fallback).
- Scope.kt: per-distribution cloud-scope.json with sensible defaults
(options.txt, config/, journeymap/data/, screenshots/). Auto-excludes
.cloud-sync/ so we never leak our own credentials.
- Sync.kt: pull = restic restore latest --target <pack-folder>;
push = restic backup --files-from <generated> --exclude-file <generated>.
Empty repos handled (pull is no-op when no snapshots yet).
- 18 tests pass. Fat jar grew to 6 MB (commons-compress).
This commit is contained in:
@@ -21,6 +21,10 @@ dependencies {
|
|||||||
// JSON via Kotlin's official lib; supports kotlin data classes natively.
|
// JSON via Kotlin's official lib; supports kotlin data classes natively.
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
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(kotlin("test"))
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>): 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<String, String?> {
|
||||||
|
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<String>, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
package center.timemachine.cloud
|
package center.timemachine.cloud
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subcommand dispatchers. Skeleton — real arg parsing + sync logic lands
|
* Subcommand dispatchers. Real work happens in Sync.kt; this layer
|
||||||
* in subsequent tasks. For now these print intent and exit cleanly so the
|
* parses args, surfaces ArgParseException as a clean error, and
|
||||||
* fat jar can be smoke-tested end-to-end through Prism / frazclient.
|
* funnels through to pull/push.
|
||||||
*/
|
*/
|
||||||
object Cli {
|
object Cli {
|
||||||
fun runPull(args: Array<String>): Int {
|
fun runPull(args: Array<String>): Int = run("pull", args, ::pull)
|
||||||
println("cloud-sync pull: ${args.joinToString(" ")}")
|
fun runPush(args: Array<String>): Int = run("push", args, ::push)
|
||||||
println("(not yet implemented; skeleton commit only)")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun runPush(args: Array<String>): Int {
|
private fun run(name: String, raw: Array<String>, action: (Args) -> Int): Int {
|
||||||
println("cloud-sync push: ${args.joinToString(" ")}")
|
val parsed = try {
|
||||||
println("(not yet implemented; skeleton commit only)")
|
parseArgs(raw)
|
||||||
return 0
|
} 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <path> (explicit override)
|
||||||
|
* 2. <pack-folder>/.cloud-sync/restic-<RESTIC_VERSION> (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<String>,
|
||||||
|
env: Map<String, String>,
|
||||||
|
cwd: Path? = null,
|
||||||
|
): Pair<Int, String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* <pack-folder>/.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<String> = DEFAULT_INCLUDE,
|
||||||
|
val exclude: List<String> = 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<Path, Path> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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/<id>/ 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<String, String> {
|
||||||
|
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:<base>/<discord_id>/ */
|
||||||
|
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<String, String> = 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 <pack-folder>.
|
||||||
|
*
|
||||||
|
* 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 <pack-folder>; the snapshot was
|
||||||
|
// built from <pack-folder> + 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
|
||||||
|
}
|
||||||
@@ -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 <pack-folder>/.cloud-sync/token
|
||||||
|
assertTrue(a.tokenFile.endsWith(".cloud-sync/token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `url required`() {
|
||||||
|
val ex = assertFailsWith<ArgParseException> { 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<ArgParseException> {
|
||||||
|
parseArgs(arrayOf("--url=https://x", "--bogus=foo"))
|
||||||
|
}
|
||||||
|
assertTrue(ex.message!!.contains("--bogus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bool flag with inline value rejected`() {
|
||||||
|
val ex = assertFailsWith<ArgParseException> {
|
||||||
|
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<ArgParseException> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOException> { 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<IOException> { readCredentials(tmp) }
|
||||||
|
assertTrue(ex.message!!.contains("malformed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty id rejected`() {
|
||||||
|
val tmp = Files.createTempFile("token-", "")
|
||||||
|
Files.writeString(tmp, ":password")
|
||||||
|
val ex = assertFailsWith<IOException> { readCredentials(tmp) }
|
||||||
|
assertTrue(ex.message!!.contains("malformed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,17 @@ import kotlin.test.assertEquals
|
|||||||
|
|
||||||
class SmokeTest {
|
class SmokeTest {
|
||||||
@Test
|
@Test
|
||||||
fun `cli pull returns 0 in skeleton`() {
|
fun `cli pull missing url returns 2`() {
|
||||||
assertEquals(0, Cli.runPull(arrayOf("--url=http://localhost:9091")))
|
assertEquals(2, Cli.runPull(emptyArray()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cli push returns 0 in skeleton`() {
|
fun `cli push missing url returns 2`() {
|
||||||
assertEquals(0, Cli.runPush(arrayOf("--url=http://localhost:9091")))
|
assertEquals(2, Cli.runPush(emptyArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cli pull unknown flag returns 2`() {
|
||||||
|
assertEquals(2, Cli.runPull(arrayOf("--url=https://x", "--bogus")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user