diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 2527c57e8..183076188 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -54,7 +54,12 @@ public final class Downloader { java.util.function.Consumer progress) { if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}")) throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters"); - return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress); + return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, progress); + } + + /** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */ + static List sources(String url, String expectedSha256) { + return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url); } // Package-private for the local multi-source HTTP test. diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 3bd000d7d..b7ca15a18 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -13,6 +13,7 @@ import com.caverock.androidsvg.SVG import com.droiddeck.launcher.R import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes import com.droiddeck.launcher.store.FlathubApi import org.json.JSONArray import org.json.JSONObject @@ -44,7 +45,7 @@ object UserApps { val fex: String = LinuxFex.AUTO, val arch: String? = null, ) - class Release(val tag: String, val published: String, val asset: String, val url: String) + class Release(val tag: String, val published: String, val asset: String, val url: String, val sha256: String? = null) sealed class UpdateCheck { class Current(val tag: String) : UpdateCheck() @@ -318,9 +319,12 @@ object UserApps { val r = releases.getJSONObject(i) if (r.optBoolean("draft")) return@mapNotNull null val assets = r.optJSONArray("assets") ?: return@mapNotNull null - val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it.getString("browser_download_url") } } + val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } } pickAsset(byName.keys.toList())?.let { asset -> - r.optBoolean("prerelease") to Release(r.optString("tag_name", asset), r.optString("published_at"), asset, byName.getValue(asset)) + r.optBoolean("prerelease") to byName.getValue(asset).let { a -> + Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"), + Hashes.githubSha256(a.optString("digest"))) + } } } val pick = (candidates.firstOrNull { !it.first } ?: candidates.firstOrNull())?.second @@ -331,7 +335,7 @@ object UserApps { val download = File(context.cacheDir, "github-${release.asset}") try { val label = context.getString(R.string.user_apps_downloading, release.asset) - val ok = Downloader.downloadFile(release.url, download, false) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } + val ok = Downloader.downloadFile(release.url, download, false, release.sha256) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } return if (ok) use(download) else context.getString(R.string.user_apps_download_failed) } finally { download.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index c79171df2..2c5a1aa0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -369,7 +369,9 @@ object WinComponents { val archiveName = archive.optString("name") val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName val file = File(work, "${c.name}.files.tar.xz") - if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { + // The snapshot came straight from github.com, so its digest may vouch for a mirror's copy of the archive. + val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) } + if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { return null to "download failed: $archiveName" } archive.optString("sha256").takeIf { it.length == 64 }?.let { sha -> diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt index a3faef070..73fd0161c 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -167,4 +167,10 @@ class DownloaderResumeTest { assertEquals(listOf(catalog), DownloadSource.candidates(catalog)) } + + @Test fun withoutADigestOnlyTheOriginalUrlIsTried() { + val url = "https://github.com/owner/repo/releases/download/v1/app.AppImage" + assertEquals(listOf(url), Downloader.sources(url, null)) + assertEquals(DownloadSource.candidates(url), Downloader.sources(url, "a".repeat(64))) + } }