31062e98b9
- 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).
247 lines
9.9 KiB
Kotlin
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
|
|
}
|
|
}
|
|
}
|