diff --git a/README.md b/README.md index ba3b9a44..7566ce5e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button. -Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Back can leave a download running, and returning to Updates shows the controller's current state. +Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state. Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available. diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt index 0c2ebae2..fc9c246b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt @@ -38,6 +38,7 @@ class HouseholdUpdateControllerTest { private var truncated = false private var chunked = false private var unsupported = false + private var manifestSigner = UpdateChannel.SIGNER private var redirect = false private var redirectTarget: String? = null private val requests = mutableListOf>>() @@ -49,12 +50,14 @@ class HouseholdUpdateControllerTest { var permission = true var settingsAvailable = true var installs = 0 + var settingsOpens = 0 + var inspectAction: (() -> Unit)? = null var outcome = InstallOutcome.Cancelled override fun installed() = installed - override fun inspect(file: File) = archive + override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive } override fun hasInstallPermission() = permission - override fun openPermissionSettings() = settingsAvailable - override fun install(file: File, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) } + override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable } + override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) } } private val preferences = object : UpdatePreferences { override val autoCheck = MutableStateFlow(true) @@ -104,7 +107,7 @@ class HouseholdUpdateControllerTest { @After fun stop() { server.stop(0) } private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code, - "2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, + "2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner, listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(), if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23))) @@ -115,17 +118,32 @@ class HouseholdUpdateControllerTest { val controller = controller(this) controller.check(true); advanceUntilIdle() assertTrue(controller.state.value is UpdateUiState.Available) + val metadata = controller.release.value + assertEquals(controller.state.value, metadata) controller.download(); advanceUntilIdle() assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) + assertEquals(metadata, controller.release.value) assertEquals(0, platform.installs) val restored = controller(this); advanceUntilIdle() assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value) + assertEquals(metadata, restored.release.value) restored.install(); advanceUntilIdle() assertEquals(UpdateUiState.InstallCancelled, restored.state.value) assertEquals(1, platform.installs) assertTrue(File(directory, "verified.apk").exists()) } + @Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest { + val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle() + val metadata = old.release.value!! + assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText())) + File(directory, "ready.json").writeText(UpdateManifest.encode(manifest())) + val recreated = controller(this); advanceUntilIdle() + assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value) + assertEquals(metadata.copy(notes = ""), recreated.release.value) + assertEquals(0, platform.installs) + } + @Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest { code = 65 val equal = controller(this); equal.check(true); advanceUntilIdle() @@ -163,6 +181,15 @@ class HouseholdUpdateControllerTest { assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind) } + @Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest { + manifestSigner = "b".repeat(64) + val controller = controller(this) + controller.check(true); advanceUntilIdle() + assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind) + assertFalse(File(directory, "verified.apk").exists()) + assertEquals(0, platform.installs) + } + @Test fun checksumAndLengthFailuresNeverCommitReady() = runTest { for (badChecksum in listOf(true, false)) { badHash = badChecksum; truncated = !badChecksum @@ -243,6 +270,53 @@ class HouseholdUpdateControllerTest { assertEquals(UpdateUiState.NoInstaller, controller.state.value) } + @Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest { + val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() + val metadata = controller.release.value + platform.permission = false + controller.install(); advanceUntilIdle() + assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) + controller.install(); advanceUntilIdle() + assertEquals(2, platform.settingsOpens) + controller.cancel(); advanceUntilIdle() + assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) + assertEquals(metadata, controller.release.value) + assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) + controller.install(); advanceUntilIdle() + assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) + assertEquals(3, platform.settingsOpens) + assertEquals(0, platform.installs) + } + + @Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest { + val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() + platform.outcome = InstallOutcome.Pending + controller.install(); advanceUntilIdle() + controller.cancel(); advanceUntilIdle() + assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) + controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle() + assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) + val before = requests.size + controller.install(); advanceUntilIdle() + controller.check(true); advanceUntilIdle() + controller.download(); advanceUntilIdle() + assertEquals(1, platform.installs) + assertEquals(before, requests.size) + assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) + controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() + controller.install(); advanceUntilIdle() + assertEquals(2, platform.installs) + } + + @Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest { + val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() + platform.inspectAction = { controller.cancel() } + controller.install(); advanceUntilIdle() + assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) + assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) + assertEquals(0, platform.installs) + } + @Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest { val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() File(directory, "verified.apk").appendText("corrupt") @@ -263,6 +337,26 @@ class HouseholdUpdateControllerTest { assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) } + @Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest { + val controller = controller(this) + releaseStatus = 403 + controller.check(true); advanceUntilIdle() + assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation) + releaseStatus = 200 + controller.retry(); advanceUntilIdle() + assetStatus = 403 + controller.download(); advanceUntilIdle() + assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation) + assetStatus = 200 + controller.retry(); advanceUntilIdle() + File(directory, "verified.apk").writeBytes(ByteArray(apk.size)) + controller.install(); advanceUntilIdle() + val failed = controller.state.value as UpdateUiState.Failed + assertEquals(UpdateOperation.Install, failed.operation) + assertEquals(UpdateFailure.ChecksumMismatch, failed.kind) + assertFalse(File(directory, "ready.json").exists()) + } + @Test fun interruptedWireDownloadDoesNotPersistReady() = runTest { truncated = true; wireTruncated = true val controller = controller(this); controller.check(true); advanceUntilIdle() @@ -289,6 +383,8 @@ class HouseholdUpdateControllerTest { controller.install(); advanceUntilIdle() assertEquals(65L, controller.installedVersion.value.versionCode) assertTrue(File(directory, "verified.apk").exists()) + controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle() + assertEquals(1, platform.installs) platform.installed = platform.archive controller.onResume(); advanceUntilIdle() assertEquals(100001L, controller.installedVersion.value.versionCode) @@ -306,6 +402,8 @@ class HouseholdUpdateControllerTest { replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() assertEquals(UpdateUiState.Idle, replaced.state.value) assertFalse(File(directory, "verified.apk").exists()) + replaced.check(true); advanceUntilIdle() + assertEquals(UpdateUiState.UpToDate, replaced.state.value) } @Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt index 561b625a..3f6b81f3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt @@ -21,19 +21,26 @@ class UpdateBehaviorTest { UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes), UpdateUiState.Offline to listOf(UpdateCommand.Retry), UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic), - ) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed") to listOf(UpdateCommand.Retry, UpdateCommand.Back) } + ) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) } expected.forEach { (state, commands) -> assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command }) } assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null)) } + @Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() { + val installed = "1.1.1" + val refreshed = presentUpdate(UpdateUiState.Idle, null, installed) + assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed) + assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command }) + assertEquals(presentUpdate(UpdateUiState.Checking, null), + presentUpdate(UpdateUiState.Checking, null, installed)) + } + @Test fun percentageRequiresPositiveTotalAndIsBounded() { listOf(null, 0L, -1L).forEach { total -> val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture()) assertNull(ui.fraction) - assertTrue(ui.title.isEmpty()) - assertNotNull(ui.byteProgress) } assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f) assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt new file mode 100644 index 00000000..4f5f3159 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import java.io.ByteArrayOutputStream +import java.security.MessageDigest +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +/** A replacement or incomplete stream must never reach the session commit boundary. */ +class UpdateSessionStreamTest { + private val original = ByteArray(128 * 1024) { (it % 251).toByte() } + private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(), + MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23) + + @Test fun sameLengthReplacementAfterVerificationIsRejected() { + val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() } + expectMismatch(replaced) + } + + @Test fun truncatedAndOversizedSessionStreamsAreRejected() { + expectMismatch(original.copyOf(original.size - 1)) + expectMismatch(original + byteArrayOf(1)) + } + + private fun expectMismatch(bytes: ByteArray) { + try { + bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) } + fail("Changed installation bytes were accepted") + } catch (error: UpdateException) { + assertEquals(UpdateFailure.ChecksumMismatch, error.kind) + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt index 504ef7ba..aa30e022 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt @@ -12,6 +12,8 @@ import android.os.Build import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File +import java.io.InputStream +import java.io.OutputStream import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @@ -47,7 +49,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false } } - override fun install(file: File, outcome: (InstallOutcome) -> Unit) { + override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { if (context.noBackupFilesDir.usableSpace < file.length()) { outcome(InstallOutcome.InsufficientStorage) return @@ -55,7 +57,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat val installer = manager.packageInstaller val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { setAppPackageName(context.packageName) - setSize(file.length()) + setSize(asset.bytes) if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) } var sessionId: Int? = null @@ -63,8 +65,8 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat sessionId = installer.createSession(parameters) check(sessions.edit().putInt("id", sessionId).commit()) installer.openSession(sessionId).use { session -> - session.openWrite("Wolfskin.apk", 0, file.length()).use { output -> - file.inputStream().use { it.copyTo(output) } + session.openWrite("Wolfskin.apk", 0, asset.bytes).use { output -> + file.inputStream().use { copyVerifiedUpdate(it, output, asset) } session.fsync(output) } val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT") @@ -72,8 +74,9 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat val result = PendingIntent.getBroadcast(context, sessionId, intent, flags) session.commit(result.intentSender) } - } catch (_: Exception) { + } catch (error: Exception) { sessionId?.let { runCatching { installer.abandonSession(it) } } + if (error is UpdateException) throw error outcome(InstallOutcome.Unavailable) } } @@ -88,3 +91,21 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat } catch (_: RuntimeException) { false } } } + +/** Check the bytes actually staged into Android, not a prior read of the private path. */ +internal fun copyVerifiedUpdate(input: InputStream, output: OutputStream, asset: UpdateAsset) { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(64 * 1024) + var count = 0L + while (true) { + val length = input.read(buffer) + if (length < 0) break + count += length + if (count > asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation") + digest.update(buffer, 0, length) + output.write(buffer, 0, length) + } + if (count != asset.bytes || digest.digest().toHexString() != asset.sha256) { + throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation") + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt index 92975631..091be7a9 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt @@ -40,26 +40,32 @@ class HouseholdUpdateController( ) : UpdateController { private val mutableState = MutableStateFlow(UpdateUiState.Idle) private val mutableVersion = MutableStateFlow(platform.installed().version) + private val mutableRelease = MutableStateFlow(null) override val state = mutableState.asStateFlow() override val installedVersion = mutableVersion.asStateFlow() override val autoCheck = preferences.autoCheck + override val release = mutableRelease.asStateFlow() private var candidate: UpdateCandidate? = null private var ready: UpdateManifest? = null private var operation: Job? = null + @Volatile private var installerActive = false private var retryOperation: () -> Unit = { check(true) } private val restored = scope.async { - ready = store.restore(abis, sdk) - ready?.let { mutableState.value = UpdateUiState.Ready(it.versionName) } + store.restore(abis, sdk)?.let { + ready = it.manifest + mutableRelease.value = available(it.manifest, it.asset, it.notes) + mutableState.value = UpdateUiState.Ready(it.manifest.versionName) + } } @Synchronized - private fun runOperation(action: suspend () -> Unit): Unit { + private fun runOperation(operationKind: UpdateOperation, action: suspend () -> Unit): Unit { if (operation?.isActive == true) return operation = scope.launch { restored.await() try { action() } catch (error: CancellationException) { throw error } - catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message) } + catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message, operationKind) } catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline } catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline } catch (error: IOException) { @@ -67,16 +73,16 @@ class HouseholdUpdateController( val full = state.value is UpdateUiState.Downloading && release != null && store.directory.usableSpace < release.asset.bytes mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network, - if (full) "Not enough storage for the update" else "Update transfer failed; try again") + if (full) "Not enough storage for the update" else "Update transfer failed; try again", operationKind) } - catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed") } + catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed", operationKind) } } } private fun systemOwnsFlow(current: UpdateUiState): Boolean = - current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded + installerActive || current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded - override fun check(manual: Boolean): Unit = runOperation { + override fun check(manual: Boolean): Unit = runOperation(UpdateOperation.Check) { if (systemOwnsFlow(state.value)) return@runOperation if (!manual && !preferences.enabled()) return@runOperation ready?.let { manifest -> @@ -86,25 +92,34 @@ class HouseholdUpdateController( retryOperation = { check(true) } mutableState.value = UpdateUiState.Checking val release = transport.latest(preferences.url(), abis, sdk) - if (release == null) { mutableState.value = UpdateUiState.NoReleasePublished; return@runOperation } + if (release == null) { + candidate = null; mutableRelease.value = null + mutableState.value = UpdateUiState.NoReleasePublished + return@runOperation + } val installed = platform.installed() mutableVersion.value = installed.version if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application") if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation") if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation") if (release.manifest.versionCode == installed.version.versionCode) { - candidate = null + candidate = null; mutableRelease.value = null mutableState.value = UpdateUiState.UpToDate - } else { candidate = release; mutableState.value = available(release) } + } else { + candidate = release + val metadata = available(release.manifest, release.asset, release.notes) + mutableRelease.value = metadata + mutableState.value = metadata + } } - private fun available(release: UpdateCandidate): UpdateUiState.Available { + private fun available(manifest: UpdateManifest, asset: UpdateAsset, notes: String): UpdateUiState.Available { val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault()) - .withZone(ZoneId.systemDefault()).format(Instant.parse(release.manifest.publishedAt)) - return UpdateUiState.Available(release.manifest.versionName, release.asset.bytes, date, release.notes) + .withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt)) + return UpdateUiState.Available(manifest.versionName, asset.bytes, date, notes) } - override fun download(): Unit = runOperation { + override fun download(): Unit = runOperation(UpdateOperation.Download) { if (systemOwnsFlow(state.value)) return@runOperation if (ready != null) return@runOperation val release = candidate ?: return@runOperation @@ -115,7 +130,7 @@ class HouseholdUpdateController( try { transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) } currentCoroutineContext().ensureActive() - store.commit(release.manifest, release.asset) + store.commit(release.manifest, release.asset, release.notes) currentCoroutineContext().ensureActive() ready = release.manifest mutableState.value = UpdateUiState.Ready(release.manifest.versionName) @@ -127,50 +142,60 @@ class HouseholdUpdateController( @Synchronized override fun cancel() { - if (systemOwnsFlow(state.value)) return transport.cancel() val previous = operation - previous?.cancel() + if (!installerActive) previous?.cancel() operation = scope.launch { previous?.join() store.partial.delete() mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) } - ?: candidate?.let { available(it) } ?: UpdateUiState.Idle + ?: candidate?.let { mutableRelease.value } ?: UpdateUiState.Idle } } - override fun install(): Unit = runOperation { - if (state.value == UpdateUiState.HandingOff) return@runOperation + override fun install(): Unit = runOperation(UpdateOperation.Install) { + if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation val manifest = ready ?: return@runOperation retryOperation = { install() } - try { store.verify(store.apk, manifest, manifest.selectAsset(abis, sdk)) } catch (error: Exception) { + val asset = manifest.selectAsset(abis, sdk) + try { + store.verify(store.apk, manifest, asset) + if (!platform.hasInstallPermission()) { + mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller + return@runOperation + } + currentCoroutineContext().ensureActive() + installerActive = true + mutableState.value = UpdateUiState.HandingOff + platform.install(store.apk, asset, ::handleInstallOutcome) + } catch (error: CancellationException) { + installerActive = false + throw error + } catch (error: Exception) { + installerActive = false store.clear(); ready = null retryOperation = { if (candidate != null) download() else check(true) } throw error } - if (!platform.hasInstallPermission()) { - mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller - return@runOperation - } - mutableState.value = UpdateUiState.HandingOff - platform.install(store.apk, ::handleInstallOutcome) } fun handleInstallOutcome(outcome: InstallOutcome) { scope.launch { restored.await() + installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success if (ready == null && outcome != InstallOutcome.Pending) { + installerActive = false retryOperation = { check(true) } mutableState.value = UpdateUiState.Idle onResume() return@launch } mutableState.value = when (outcome) { - InstallOutcome.Pending -> UpdateUiState.HandingOff + InstallOutcome.Pending -> if (state.value is UpdateUiState.Ready) state.value else UpdateUiState.HandingOff InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled InstallOutcome.Unavailable -> UpdateUiState.NoInstaller - InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update") - InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained") + InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update", UpdateOperation.Install) + InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained", UpdateOperation.Install) InstallOutcome.Success -> UpdateUiState.HandingOff } if (outcome != InstallOutcome.Pending) onResume() @@ -186,7 +211,8 @@ class HouseholdUpdateController( mutableVersion.value = platform.installed().version val manifest = ready ?: return@launch if (mutableVersion.value.versionCode >= manifest.versionCode) { - store.clear(); ready = null; candidate = null; mutableState.value = UpdateUiState.Idle + installerActive = false + store.clear(); ready = null; candidate = null; mutableRelease.value = null; mutableState.value = UpdateUiState.Idle } else if (state.value == UpdateUiState.PermissionNeeded) { // Returning from settings requires another explicit Install press. mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt index f7913df2..75803a95 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -13,7 +13,7 @@ interface UpdateController { fun check(manual: Boolean) fun download() - /** Cancel partial bytes, or dismiss permission/handoff while retaining a verified APK. */ + /** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */ fun cancel() fun install() fun retry() @@ -38,11 +38,14 @@ sealed interface UpdateUiState { data object PermissionNeeded : UpdateUiState data object HandingOff : UpdateUiState data object InstallCancelled : UpdateUiState - data class Failed(val kind: UpdateFailure, val message: String) : UpdateUiState + data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState data object Offline : UpdateUiState data object NoInstaller : UpdateUiState } +/** The failed action is structured; UI must not infer it from a reason string. */ +enum class UpdateOperation { Check, Download, Install } + /** Failures are distinct from a successful version comparison or an empty channel. */ enum class UpdateFailure { Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage, diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt index 3bc8c628..a052fac5 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt @@ -38,13 +38,12 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat val retry = UpdateAction("Retry", UpdateCommand.Retry, true) val metadata = updateMetadata(release) return when (state) { - UpdateUiState.Idle -> UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true))) + UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation) + ?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true))) UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true) - UpdateUiState.UpToDate -> UpdatePresentation( - title = updatedVersion?.let { "Updated to $it" } ?: "Up to date", - summary = if (updatedVersion != null) "Sign-ins and settings kept." else "No newer release for this TV.", - actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)), - ) + UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation) + ?: UpdatePresentation("Up to date", "No newer release for this TV.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true))) UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.", actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true))) is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state), @@ -66,7 +65,7 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel))) UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata, listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes)) - is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind), state.message, actions = listOf(retry, back)) + is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back)) UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.", actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true))) UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.", @@ -75,9 +74,19 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat } } -private fun failureTitle(kind: UpdateFailure): String = when (kind) { - UpdateFailure.Network -> "Check failed" - UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> "Download failed" - UpdateFailure.InvalidMetadata, UpdateFailure.UnsupportedAbi -> "Update unavailable" - UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed" +private fun updatedPresentation(version: String) = UpdatePresentation( + "Updated to $version", "Sign-ins and settings kept.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)), +) + +private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String { + if (operation == UpdateOperation.Install) return "Install failed" + return when (kind) { + UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> + if (operation == UpdateOperation.Check) "Check failed" else "Download failed" + UpdateFailure.InvalidMetadata -> + if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed" + UpdateFailure.UnsupportedAbi -> "Update unavailable" + UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed" + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt index 1d270736..0db04aa7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt @@ -71,10 +71,10 @@ fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> val installed by controller.installedVersion.collectAsStateWithLifecycle() val release by controller.release.collectAsStateWithLifecycle() val autoCheck by controller.autoCheck.collectAsStateWithLifecycle() - val initialInstalled = remember { installed.versionName } - val updatedVersion = installed.versionName.takeIf { it != initialInstalled } + val initialInstalledCode = remember { installed.versionCode } + val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode } val presentation = remember(state, release, updatedVersion) { - presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.UpToDate }) + presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate }) } val scroll = rememberScrollState() val scope = rememberCoroutineScope() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt index 5018865b..a28e80c1 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt @@ -3,6 +3,12 @@ package org.justinvisser.wolfskin.tv.update import java.io.File import java.security.MessageDigest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive /** Actual APK identity, read independently of channel metadata. */ data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set) @@ -13,12 +19,15 @@ interface UpdatePlatform { fun inspect(file: File): ApkIdentity? fun hasInstallPermission(): Boolean fun openPermissionSettings(): Boolean - fun install(file: File, outcome: (InstallOutcome) -> Unit) + fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) } /** Installer results do not imply the running process has been replaced. */ enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage } +/** Metadata returned only after verifying the persisted APK against this release. */ +data class VerifiedRelease(val manifest: UpdateManifest, val asset: UpdateAsset, val notes: String) + /** Private staging; a metadata commit occurs only after independently verifying the APK. */ class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) { val partial = File(directory, "download.part") @@ -52,23 +61,28 @@ class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatf } } - fun commit(manifest: UpdateManifest, asset: UpdateAsset) { + fun commit(manifest: UpdateManifest, asset: UpdateAsset, notes: String) { verify(partial, manifest, asset) check(partial.renameTo(apk)) { "Could not commit verified APK" } val temporary = File(directory, "ready.pending") - temporary.outputStream().use { it.write(UpdateManifest.encode(manifest).toByteArray()); it.fd.sync() } + val metadata = Json.parseToJsonElement(UpdateManifest.encode(manifest)).jsonObject + val stored = JsonObject(metadata + ("releaseNotes" to JsonPrimitive(notes))) + temporary.outputStream().use { it.write(stored.toString().toByteArray()); it.fd.sync() } check(temporary.renameTo(record)) { "Could not commit verified metadata" } } /** Re-hash and re-inspect after process death; a marker alone never establishes trust. */ - fun restore(abis: List, sdk: Int): UpdateManifest? { + fun restore(abis: List, sdk: Int): VerifiedRelease? { partial.delete() File(directory, "ready.pending").delete() if (!record.exists()) { apk.delete(); return null } return try { - val manifest = UpdateManifest.parse(record.readText()) - verify(apk, manifest, manifest.selectAsset(abis, sdk)) - manifest + val text = record.readText() + val manifest = UpdateManifest.parse(text) + val asset = manifest.selectAsset(abis, sdk) + verify(apk, manifest, asset) + val notes = Json.parseToJsonElement(text).jsonObject["releaseNotes"]?.jsonPrimitive?.contentOrNull.orEmpty() + VerifiedRelease(manifest, asset, notes) } catch (_: Exception) { clear(); null } } diff --git a/docker/README.md b/docker/README.md index dde55ef2..92dc7adf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -72,11 +72,13 @@ make tv-release VERSION=1.1.1 VERSION_CODE=100001 \ Required: a clean committed source checkout, the existing persistent key, native package credentials through the existing secret mount, no local AAR overrides, -and an explicit code above every entry in `docker/tv-version-codes`. Known Opt -code 65 seeds that ledger; old production code 28 and Wholphin code 62 belong -to different packages. Proposed first household code 100001 exceeds all three. -No household code derives from tag counts. Direct optimized Gradle tasks also -require `-PtvVersionCode`; the packaging command additionally enforces the ledger. +and an explicit code above the committed ledgers at HEAD and freshly fetched +`origin/main`, plus every local consumed-code reservation. Fetch failure refuses +the build, including a dry-run. Known Opt code 65 seeds the ledger; old production +code 28 and Wholphin code 62 belong to different packages. Proposed first household +code 100001 exceeds all three. No household code derives from tag counts. +Only `make tv-release` produces an installable household release; direct optimized +Gradle builds bypass reservation and must never be installed on the household box. The builder inspects final signed APKs with aapt2/apksigner and checks package, version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory, @@ -91,8 +93,13 @@ Manifest schema 1 contains `packageId`, `versionName`, `versionCode`, `signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`. All ABI outputs share one code/signer/identity. The local source tag is `pwolfskin-opt-v`; the distribution tag is `wolfskin-opt-v`. +Before building, the code is consumed in the local Git ref +`refs/wolfskin-tv/version-codes/`. This survives discarding a working-tree +ledger append or checking out an older source commit. A failed real build also +consumes its code: use a higher code rather than deleting the reservation. After successful packaging the builder appends the code to the tracked ledger. -Commit that reservation and explicitly push the source tag before distribution. +Commit that reservation and explicitly push the source tag before distribution; +new producer checkouts obtain the committed floor by fetching `origin/main`. An existing output directory is refused to preserve previous evidence. Keep `private/` private: build/dependency logs, R8 mapping/effective configuration, @@ -120,6 +127,12 @@ and reverified. Upload/verification failures remain draft. Promotion requires the explicit flag; then anonymous latest/asset hash checks must pass. A failed post-promotion check attempts withdrawal to draft and reports failure, not success. Cross-origin credential forwarding and non-HTTPS URLs are refused. + +Before creating/resuming a draft and immediately before promotion, the publisher +anonymously reads latest and its manifest. The bundle code must be strictly newer. +Only a latest 404 followed by a successful empty release list permits a first +release; other errors fail closed. A channel advance during upload leaves the +verified upload draft rather than publishing an equal or lower code. `--test-server` permits only a loopback HTTP fake Forgejo; production defaults cannot be redirected to a different repository by a CLI repository argument. Dry-run never fetches a credential or makes publication requests. @@ -132,6 +145,16 @@ explicit check/download/cancel/install/retry/resume actions. Its capture fake does not perform real operations. Classic Opt update controls use that same controller; the old package retains its original updater. +`release: StateFlow` retains version, size, localized +date and release notes through download, Ready and installation cancellation. +Verified records persist notes as an optional `releaseNotes` field alongside +the manifest; existing manifest-only records remain readable. `Failed.operation` +is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text. +Permission and handoff dismissals restore verified Ready without discarding its +APK. Installer ownership is tracked separately from the visible state, so a +dismissed but active system session still blocks a duplicate handoff. Calling +`install()` from PermissionNeeded resolves and opens the permission settings again. + The client uses a dedicated anonymous OkHttp client, validating every redirect: HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential. Loopback HTTP is available only through the test transport constructor flag. @@ -151,6 +174,8 @@ the installed Android signer is the installation boundary. EOF never starts installation. Install requires a separate OK, per-package unknown-app permission and a confirmation-required PackageInstaller session. +The bytes streamed into that session are checked again against the manifest length +and hash; any mismatch abandons the session before commit and discards invalid Ready. Returning from permission settings requires another explicit Install press. Denial, cancellation and missing system handlers retain the verified download. No broad FileProvider, public Downloads write, silent install or automatic @@ -228,13 +253,15 @@ monotonic versionCode; source commit identity is recorded separately. `make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and release signer. It must never publish to the household channel. -Build all four default-flavor outputs in one invocation through the container -wrapper, without a release keystore. On a host whose Gradle cache already holds -the pinned native extensions and their metadata: +For build-only diagnostics, build all four outputs through the container wrapper +with a deliberately non-release code. **Direct optimized builds must never be +installed on the household box or published.** Use `make tv-release` for every +household installation. On a host whose Gradle cache already holds the pinned +native extensions and their metadata: ```sh bash docker/gradle.sh optimized --offline \ - -PtvVersion=1.1.1 -PtvVersionCode=100001 \ + -PtvVersion=1.1.1 -PtvVersionCode=1 \ -PWholphinExtensionsUsername=JustinZeus \ -PWholphinExtensionsPassword=unused \ :app:assembleDefaultOptimized @@ -247,7 +274,8 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and or securely seeded cached artifacts. No release signing inputs are needed. Without native extension configuration, the existing dependency selection uses the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a -playback-complete comparison. Inspect native libraries before installing. +playback-complete comparison. Inspect native libraries for diagnostic evidence; +do not install these direct-build APKs on the household box. Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, armeabi-v7a and x86_64. Keep diff --git a/docker/test_tv_publish.py b/docker/test_tv_publish.py index 2c7673d8..86ccce49 100644 --- a/docker/test_tv_publish.py +++ b/docker/test_tv_publish.py @@ -30,6 +30,12 @@ class FakeForge: self.corrupt_draft = False self.corrupt_public = False self.redirect_url: str | None = None + self.published_code: int | None = None + self.channel_manifest: dict[str, Any] = {} + self.latest_status = 200 + self.release_list: list[dict[str, Any]] = [] + self.latest_reads = 0 + self.advance_before_promote: int | None = None fixture = self class Handler(BaseHTTPRequestHandler): @@ -104,10 +110,39 @@ class FakeForge: else fixture.corrupt_public ) self.respond(data + b"corrupt" if corrupt else data) - elif self.path.endswith("/latest") and fixture.release.get( - "draft", True - ): - self.respond({}, 404) + elif self.path == "/channel/wolfskin-update.json": + self.respond( + fixture.channel_manifest + | {"versionCode": fixture.published_code} + ) + elif self.path.endswith("/latest"): + fixture.latest_reads += 1 + if ( + fixture.latest_reads == 2 + and fixture.advance_before_promote is not None + ): + fixture.published_code = fixture.advance_before_promote + if fixture.latest_status != 200: + self.respond({}, fixture.latest_status) + elif fixture.release and not fixture.release["draft"]: + self.respond(fixture.release) + elif fixture.published_code is not None: + self.respond( + { + "id": 9, + "assets": [ + { + "name": "wolfskin-update.json", + "browser_download_url": fixture.base + + "/channel/wolfskin-update.json", + } + ], + } + ) + else: + self.respond({}, 404) + elif urllib.parse.urlsplit(self.path).path.endswith("/releases"): + self.respond(fixture.release_list) else: self.respond(fixture.release) @@ -157,6 +192,7 @@ class PublishTest(unittest.TestCase): "signerSha256": SIGNER, "assets": assets, } + self.forge.channel_manifest = manifest (self.directory / "wolfskin-update.json").write_text(json.dumps(manifest)) (self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes") self.checksums() @@ -208,16 +244,18 @@ class PublishTest(unittest.TestCase): self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.forge.release["draft"]) self.assertEqual(REQUIRED, set(self.forge.assets)) - self.assertTrue(all(auth for _, auth in self.forge.events)) + self.assertTrue( + all( + not auth + for path, auth in self.forge.events + if "/latest" in path or "?limit=" in path + ) + ) result = self.cli("--draft-id", "17", "--promote") self.assertEqual(0, result.returncode, result.stderr) self.assertFalse(self.forge.release["draft"]) for name, data in self.forge.assets.items(): self.assertEqual((self.directory / name).read_bytes(), data) - anonymous = [ - event for event, authenticated in self.forge.events if not authenticated - ] - self.assertEqual(8, len(anonymous)) def test_corrupt_uploaded_bytes_remain_draft(self) -> None: self.forge.corrupt_draft = True @@ -266,6 +304,59 @@ class PublishTest(unittest.TestCase): self.assertEqual(0, result.returncode, result.stderr) self.assertFalse(trace.exists()) + def test_equal_and_lower_bundle_codes_never_create_a_draft(self) -> None: + for published_code in (100001, 100002): + with self.subTest(published_code=published_code): + self.forge.published_code = published_code + result = self.cli("--promote") + self.assertNotEqual(0, result.returncode) + self.assertEqual({}, self.forge.release) + self.assertTrue(all(not auth for _, auth in self.forge.events)) + + def test_newer_bundle_reads_live_manifest_anonymously(self) -> None: + self.forge.published_code = 100000 + result = self.cli() + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(self.forge.release["draft"]) + live_reads = [ + auth + for path, auth in self.forge.events + if path.endswith("/latest") or path.startswith("GET /channel/") + ] + self.assertTrue(live_reads) + self.assertFalse(any(live_reads)) + + def test_channel_advance_during_upload_prevents_promotion(self) -> None: + self.forge.advance_before_promote = 100001 + result = self.cli("--promote") + self.assertNotEqual(0, result.returncode) + self.assertTrue(self.forge.release["draft"]) + self.assertFalse(any(path == "PATCH" for path, _ in self.forge.events)) + + def test_empty_channel_404_permits_first_draft(self) -> None: + result = self.cli() + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(self.forge.release["draft"]) + self.assertIn( + ( + "GET /api/v1/repos/justinzeus/wolfskin-tv-updates/releases?limit=1", + False, + ), + self.forge.events, + ) + + def test_missing_latest_with_nonempty_channel_is_not_first_release(self) -> None: + self.forge.release_list = [{"id": 9}] + result = self.cli() + self.assertNotEqual(0, result.returncode) + self.assertEqual({}, self.forge.release) + + def test_refused_live_channel_never_creates_draft(self) -> None: + self.forge.latest_status = 403 + result = self.cli() + self.assertNotEqual(0, result.returncode) + self.assertEqual({}, self.forge.release) + def test_authenticated_redirect_never_reaches_another_origin(self) -> None: other = FakeForge() self.addCleanup(other.close) diff --git a/docker/test_tv_release.py b/docker/test_tv_release.py index 31dbef1f..4a5d679a 100644 --- a/docker/test_tv_release.py +++ b/docker/test_tv_release.py @@ -34,6 +34,12 @@ class ReleaseInputTest(unittest.TestCase): "-m", "Record used code", ) + self.remote = Path(self.temporary.name) / "origin.git" + subprocess.run( + ["git", "init", "--bare", "--quiet", str(self.remote)], check=True + ) + self.git("remote", "add", "origin", str(self.remote)) + self.git("push", "--quiet", "origin", "HEAD:main") self.previous_root = tv_release.ROOT tv_release.ROOT = self.root self.addCleanup(setattr, tv_release, "ROOT", self.previous_root) @@ -94,6 +100,59 @@ class ReleaseInputTest(unittest.TestCase): self.assertEqual( "65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text() ) + self.assertEqual( + "", self.git("for-each-ref", "refs/wolfskin-tv/version-codes/") + ) + + def test_stale_branch_uses_fetched_main_ledger(self) -> None: + stale = self.git("rev-parse", "HEAD") + (self.root / "docker/tv-version-codes").write_text( + "65 installed-opt\n100001 released\n" + ) + self.git("add", "docker/tv-version-codes") + self.git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--quiet", + "-m", + "Reserve published code", + ) + self.git("push", "--quiet", "origin", "HEAD:main") + self.git("checkout", "--quiet", "--detach", stale) + with self.assertRaises(ValueError): + tv_release.validate_inputs("1.1.2", 100001) + self.assertEqual(stale, tv_release.validate_inputs("1.1.2", 100002)) + + def test_discarding_ledger_append_cannot_reuse_reserved_code(self) -> None: + commit = self.git("rev-parse", "HEAD") + tv_release.reserve_code(100001, commit) + with (self.root / "docker/tv-version-codes").open("a") as ledger: + ledger.write("100001 1.1.1 consumed\n") + self.git("restore", "docker/tv-version-codes") + with self.assertRaises(ValueError): + tv_release.validate_inputs("1.1.2", 100001) + self.assertEqual(commit, tv_release.validate_inputs("1.1.2", 100002)) + + def test_head_reservation_wins_over_older_main(self) -> None: + (self.root / "docker/tv-version-codes").write_text( + "65 installed-opt\n100002 released\n" + ) + self.git("add", "docker/tv-version-codes") + self.git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--quiet", + "-m", + "Reserve newer branch code", + ) + with self.assertRaises(ValueError): + tv_release.validate_inputs("1.1.3", 100002) if __name__ == "__main__": diff --git a/docker/tv_publish.py b/docker/tv_publish.py index 99f4d040..7a1a131f 100755 --- a/docker/tv_publish.py +++ b/docker/tv_publish.py @@ -30,6 +30,14 @@ REQUIRED = { } +class ForgeHttpError(RuntimeError): + """Retain HTTP status without exposing response bodies or authorization.""" + + def __init__(self, method: str, status: int) -> None: + super().__init__(f"Forgejo {method} failed: HTTP {status}") + self.status = status + + class NoRedirect(urllib.request.HTTPRedirectHandler): """Validate every redirect before forwarding distribution-tool credentials.""" @@ -98,9 +106,9 @@ class Forge: return self.opener.open(request, timeout=60) except urllib.error.HTTPError as error: if method != "GET" or error.code not in (301, 302, 303, 307, 308): - raise RuntimeError( - f"Forgejo {method} failed: HTTP {error.code}" - ) from None + status = error.code + error.close() + raise ForgeHttpError(method, status) from None location = error.headers.get("Location") error.close() if not location: @@ -159,6 +167,41 @@ class Forge: if actual != expected: raise ValueError("Uploaded artifact checksum mismatch") + def require_newer_code(self, code: int) -> None: + """Read the public channel anonymously and fail closed on an uncertain floor.""" + try: + release = self.json("GET", "/releases/latest", authenticated=False) + except ForgeHttpError as error: + if error.status != 404: + raise + releases = self.json("GET", "/releases?limit=1", authenticated=False) + if releases == []: + return + raise ValueError("Latest is missing but the channel is not empty") from None + assets = [ + asset + for asset in release["assets"] + if asset["name"] == "wolfskin-update.json" + ] + if len(assets) != 1: + raise ValueError("Latest release must have exactly one update manifest") + with self.request( + "GET", assets[0]["browser_download_url"], authenticated=False + ) as response: + manifest = json.load(response) + latest_code = manifest["versionCode"] + if ( + manifest["schemaVersion"] != 1 + or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt" + or type(latest_code) is not int + or not 1 <= latest_code <= 2100000000 + ): + raise ValueError("Invalid live household version identity") + if code <= latest_code: + raise ValueError( + f"Bundle versionCode must exceed live channel code {latest_code}" + ) + def credential(server: str) -> str: """Read git's existing credential helper without secrets in argv, stdout or disk.""" @@ -280,6 +323,7 @@ def publish( f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials" ) return + Forge(server, "", test_server).require_newer_code(manifest["versionCode"]) forge = Forge(server, credential(server), test_server) if draft_id is None: release = forge.json( @@ -313,6 +357,7 @@ def publish( "Draft upload verified. Publication requires owner approval and explicit --promote." ) return + forge.require_newer_code(manifest["versionCode"]) try: forge.json("PATCH", f"/releases/{release_id}", {"draft": False}) latest = forge.json("GET", "/releases/latest", authenticated=False) diff --git a/docker/tv_release.py b/docker/tv_release.py index 8a147ce3..5ce4b890 100755 --- a/docker/tv_release.py +++ b/docker/tv_release.py @@ -37,16 +37,40 @@ def validate_inputs(version: str, code: int) -> str: raise ValueError( "Release source must be clean, including the version-code ledger" ) - used = [ - int(line.split()[0]) - for line in (ROOT / "docker/tv-version-codes").read_text().splitlines() - if line and not line.startswith("#") - ] + commit = run("git", "rev-parse", "HEAD") + run( + "git", + "fetch", + "--no-tags", + "origin", + "refs/heads/main:refs/remotes/origin/main", + ) + used = [] + for reference in (commit, "refs/remotes/origin/main"): + if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"): + ledger = run("git", "show", f"{reference}:docker/tv-version-codes") + used.extend( + int(line.split()[0]) + for line in ledger.splitlines() + if line and not line.startswith("#") + ) + reservations = run( + "git", + "for-each-ref", + "--format=%(refname:strip=3)", + "refs/wolfskin-tv/version-codes/", + ) + used.extend(int(value) for value in reservations.splitlines()) if code <= max(used): - raise ValueError(f"VERSION_CODE must exceed ledger maximum {max(used)}") + raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}") if list((ROOT / "app/libs").glob("*.aar")): raise ValueError("Local AAR overrides are forbidden for household releases") - return run("git", "rev-parse", "HEAD") + return commit + + +def reserve_code(code: int, commit: str) -> None: + """Keep a consumed code reserved even if its working-tree ledger append is discarded.""" + run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "") def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int: @@ -130,6 +154,7 @@ def release(version: str, code: int, output: Path, dry_run: bool) -> None: existing = run("git", "tag", "--list", tag) if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit: raise ValueError("Source tag already identifies another commit") + reserve_code(code, commit) output.mkdir(parents=True) private = output / "private" private.mkdir(mode=0o700)