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