Files
cloud-sync/src/main/kotlin/center/timemachine/cloud/Restic.kt
T
claude-timemachine 31062e98b9
CI / build (push) Failing after 2s
CI / release (push) Has been skipped
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).
2026-06-02 22:41:00 +02:00

247 lines
9.9 KiB
Kotlin

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
}
}
}