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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user