From d1e0f3d646d89f56b8d098a4a3ee340a62abf14a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 01:27:38 +0200 Subject: [PATCH 01/49] fix: explain dynamic app contract fallbacks without exception text Classify App Store contract acquisition failures in the contract module and translate them once in shared JVM code for Android and desktop. A runtime linkage failure, such as a static initializer the Android regex engine rejects, now becomes a client-fault fallback instead of escaping with a class name as its message. Record a typed fallback reason on metadata-only discoveries and derive the fallback card copy from it, so missing contracts, unconfirmed versions, unreachable or unavailable App Store releases, rejected packages, and unsupported contracts each explain what happened. Extend the brace regex architecture check to Android-only source sets and initialize every contract module facade in the Android instrumented test. --- DYNAMIC_APP_DESCRIPTOR.md | 34 +++- PLATFORMS.md | 11 +- .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 28 +++ .../AndroidNextcloudServices.kt | 37 +--- .../473-explained-app-contract-fallbacks.md | 7 + ...ndroid-contract-initialization-fallback.md | 7 + .../contracts/ContractAcquisitionFailure.kt | 75 ++++++++ .../SignedAppStoreContractAcquirer.kt | 60 +++---- .../ContractAcquisitionFailureTest.kt | 162 ++++++++++++++++++ tools/check-kotlin-architecture.sh | 6 +- tools/kotlin-file-size-baseline.txt | 8 +- tools/test-kotlin-architecture.sh | 6 + ui/build.gradle.kts | 2 + .../app/DynamicContractFallback.kt | 126 ++++++++++++++ .../app/DynamicNativeRuntime.kt | 37 ++-- .../nextcloudnative/app/NextcloudNativeApp.kt | 12 +- .../app/DesktopNextcloudServices.kt | 34 +--- .../DynamicContractFallbackDiscoveryTest.kt | 144 ++++++++++++++++ .../app/JvmAppStoreContractAcquisitionTest.kt | 101 +++++++++++ .../app/JvmAppStoreContractAcquisition.kt | 66 +++++++ 20 files changed, 830 insertions(+), 133 deletions(-) create mode 100644 changes/unreleased/473-explained-app-contract-fallbacks.md create mode 100644 changes/unreleased/493-android-contract-initialization-fallback.md create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index d383f9116..221b5029c 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -177,6 +177,31 @@ or request URL. Cancellation propagates without a failure event, and a recorder failure never discards a usable contract. An unverified app version keeps writes disabled without showing a permanent refresh spinner. +## Metadata fallback reasons + +When no same-origin or App Store source yields a verified contract, discovery +keeps the metadata-only descriptor and records a typed reason in +[`DynamicContractFallback.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt). +The fallback card derives its title, explanation, and action from that reason: + +- no verified contract exists for the installed version; +- the server or app version could not be confirmed; +- the App Store could not be reached, or did not provide the release; +- the package or its source failed verification; +- the verified contract cannot be shown natively yet; +- the client could not complete the check. + +[`ContractAcquisitionFailure.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt) +classifies the module's own failures: HTTP status responses, verification and +origin rejections, malformed request values, network failures, and runtime +linkage errors. +[`JvmAppStoreContractAcquisition.kt`](ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt) +translates them once for both JVM targets. It also catches `LinkageError`. If +acquisition code cannot initialize on a runtime, for example because the +Android ICU engine rejects a regular expression, the result is a client-fault +fallback, not an exception class name. Diagnostics and product copy never +contain exception text, and a fallback reason is never persisted. + ## Read failures [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) @@ -213,10 +238,11 @@ bodies, invalid text, and deeply nested JSON. non-default ports, IPv6, explicit mismatches, and untrusted templates. - Acquisition rejects concrete package-server authorities because it cannot prove the connected account owns them. -- Host placeholders are parsed without a brace regular expression (the `ui` - build checks `commonMain` for this). The contract module's ownership boundary - is also exercised by an Android instrumented test, to verify class - initialization and host rejection on the Android runtime. +- Host placeholders are parsed without a brace regular expression. The `ui` + build checks `commonMain`, and `tools/check-kotlin-architecture.sh` checks + every source set that runs on Android. An Android instrumented test + initializes every top-level contract-module class and exercises host + rejection on the Android runtime. - Path and operation server overrides must pass the same origin checks and resolve to the root server path base. Different override bases are rejected, not ignored. An absent override inherits the base; an explicit empty array diff --git a/PLATFORMS.md b/PLATFORMS.md index 2aa943643..cbca87d59 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -33,11 +33,11 @@ modules (`settings.gradle.kts`); the Rust crate is built separately. | Location | What it owns | | --- | --- | | `ui/src/commonMain` | Platform-neutral models, policies, repositories, the dynamic descriptor compiler (`nativeui/model`), the native renderer (`nativeui/runtime`), and Compose screens. The `NextcloudPlatformServices` interface is declared here. | -| `ui/src/jvmMain` | Code that is identical on Android and desktop: OkHttp request helpers, the authenticated app-read session, detached downloads, resumable uploads, text-draft encryption, and support diagnostics. | +| `ui/src/jvmMain` | Code that is identical on Android and desktop: OkHttp request helpers, the authenticated app-read session, the App Store contract acquisition boundary, detached downloads, resumable uploads, text-draft encryption, and support diagnostics. | | `ui/src/androidMain` | Android `actual` implementations used by the shared UI, such as audio and video playback services, image decoding, back handling, the embedded Office editor, and server certificate trust. | | `ui/src/desktopMain` | The complete desktop platform: `DesktopNextcloudServices`, secret stores, the desktop sync engine and its SQLite stores, Linux FUSE virtual files, Windows Cloud Files, tray integration, and packaging resources. | | `androidApp/src/main` | The Android application: `MainActivity`, `AndroidNextcloudServices`, Keystore-backed credentials, WorkManager workers, `NextcloudDocumentsProvider`, folder sync, media backup, and share-target activities. | -| `contractAcquisition` | A plain Kotlin/JVM module that verifies signed App Store packages and extracts app contracts. Both `androidApp` and the desktop target depend on it; `commonMain` does not. | +| `contractAcquisition` | A plain Kotlin/JVM module that verifies signed App Store packages and extracts app contracts. `androidApp` and both `ui` JVM targets depend on it, so shared `jvmMain` code can translate its results; `commonMain` does not. | | `src/` (Rust crate) | The reference schema and descriptor compiler, plus the Windows Explorer shell registrar binary that the desktop Windows package bundles. The Rust compiler is not linked into the app. | `jvmMain` is not a separate Kotlin Multiplatform source set. The `ui` build adds @@ -72,9 +72,10 @@ figures came from comparing function bodies after removing the `Android` and conflicts, checkpoints), groupware and media DAV models, virtual-file eviction policy, and the `NextcloudPlatformServices` interface live in `commonMain`. Both service containers implement that interface. `jvmMain` shares login and -authenticated request policy, application read sessions, detached downloads, -the resumable chunk-upload state machine, staging-space reservations, support -diagnostics and intake, text-draft encryption, and WebDAV href decoding. +authenticated request policy, application read sessions, App Store contract +acquisition results and failures, detached downloads, the resumable +chunk-upload state machine, staging-space reservations, support diagnostics and +intake, text-draft encryption, and WebDAV href decoding. **Still duplicated.** The gap is below the shared interface: each platform has its own HTTP request code, protocol adapters, XML parsers, and sync executor. diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 8309bc11a..a4cf5e8fb 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -1,7 +1,12 @@ package dev.obiente.nextcloudnative import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.obiente.nextcloudnative.app.AppStoreContractAcquisitionException +import dev.obiente.nextcloudnative.app.AppStoreContractFailureKind +import dev.obiente.nextcloudnative.app.acquireAppStoreContract +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import java.lang.reflect.Method +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -35,6 +40,29 @@ class AppOwnedOpenApiAndroidInstrumentedTest { } } + @Test + fun everyContractStaticInitializerAndFailureBoundaryWorkOnAndroid() { + // Initialize each packaged top-level facade and companion owner on ART and ICU. + listOf( + "AppOwnedOpenApiContractKt", "ContractAcquisitionFailureKt", "SignedAppStoreContractAcquirerKt", + "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", + "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", + "FileAppStoreCatalogCache", "DynamicApiResponseCache", "FileVerifiedContractCache", + ).forEach { name -> Class.forName("dev.obiente.nextcloudnative.contracts.$name") } + Class.forName("dev.obiente.nextcloudnative.app.JvmAppStoreContractAcquisitionKt") + + val failed = try { + acquireAppStoreContract(ContractAcquisitionRequest("example", "34.0.3", "1.0.0")) { + throw NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.SyntheticKt") + } + null + } catch (failure: AppStoreContractAcquisitionException) { + failure + } + assertEquals(AppStoreContractFailureKind.RuntimeIncompatible, failed?.kind) + assertFalse(failed?.message.orEmpty().contains("SyntheticKt")) + } + private fun Method.accepts(server: String): Boolean { val document = """{"openapi":"3.1.1","servers":[{"url":"$server"}],"paths":{"/items":{}}}""" return invoke(null, "example", "openapi.json", document) as Boolean diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 9ad3dae42..d77fc857e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,8 +14,7 @@ import android.provider.Settings import android.util.Base64 import android.util.Log import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract -import dev.obiente.nextcloudnative.app.AcquiredOpenApiContractSourceKind -import dev.obiente.nextcloudnative.app.AcquiredContractKind +import dev.obiente.nextcloudnative.app.acquireAppStoreContract import dev.obiente.nextcloudnative.app.DeckAttachment import dev.obiente.nextcloudnative.app.DeckAttachmentOpenTarget import dev.obiente.nextcloudnative.app.DeckCardDraftKey @@ -230,11 +229,9 @@ import dev.obiente.nextcloudnative.app.systemTagsDavDiscoveryRequest import dev.obiente.nextcloudnative.app.toWebDavMutationSpec import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse -import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache import dev.obiente.nextcloudnative.contracts.FileVerifiedContractCache import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer -import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import java.io.File import java.io.ByteArrayOutputStream import java.io.FileInputStream @@ -2966,34 +2963,10 @@ internal class AndroidNextcloudServices( serverVersion: String, installedAppVersion: String?, ): AcquiredOpenApiContract? = withContext(Dispatchers.IO) { - contractAcquirer.acquire(ContractAcquisitionRequest(appId, serverVersion, installedAppVersion)) - ?.let { contract -> - AcquiredOpenApiContract( - appId = contract.appId, - appVersion = contract.appVersion, - contractVersion = contract.contractVersion, - specFile = contract.specFile, - document = contract.document, - packageUrl = contract.packageUrl, - sourceUrl = contract.sourceUrl, - sourceKind = when (contract.sourceKind) { - OpenApiContractSourceKind.SignedAppPackage -> - AcquiredOpenApiContractSourceKind.SignedAppPackage - OpenApiContractSourceKind.SignedCompatibleAppPackage -> - AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage - OpenApiContractSourceKind.AppStoreLinkedExactGitHubTag -> - AcquiredOpenApiContractSourceKind.AppStoreLinkedExactGitHubTag - OpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag -> - AcquiredOpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag - }, - contractKind = when (contract.contractKind) { - VerifiedContractKind.OpenApi -> AcquiredContractKind.OpenApi - VerifiedContractKind.VerifiedReadRoutes -> AcquiredContractKind.VerifiedReadRoutes - VerifiedContractKind.OpenApiWithVerifiedReadRoutes -> - AcquiredContractKind.OpenApiWithVerifiedReadRoutes - }, - ) - } + acquireAppStoreContract( + ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), + contractAcquirer::acquire, + ) } override suspend fun listActivities( diff --git a/changes/unreleased/473-explained-app-contract-fallbacks.md b/changes/unreleased/473-explained-app-contract-fallbacks.md new file mode 100644 index 000000000..37fde9ba5 --- /dev/null +++ b/changes/unreleased/473-explained-app-contract-fallbacks.md @@ -0,0 +1,7 @@ +category: fix +issue: 473 +pull: none +platforms: android, desktop +user-facing: yes + +An app without a verified native view now explains why, such as no verified contract for its version or an unreachable App Store, and checking again reports the same reason. diff --git a/changes/unreleased/493-android-contract-initialization-fallback.md b/changes/unreleased/493-android-contract-initialization-fallback.md new file mode 100644 index 000000000..e460d6961 --- /dev/null +++ b/changes/unreleased/493-android-contract-initialization-fallback.md @@ -0,0 +1,7 @@ +category: fix +issue: 493 +pull: none +platforms: android, desktop +user-facing: yes + +Dynamic apps on Android no longer fail App Store contract discovery because a contract parser could not initialize. If contract code cannot start on a device, the app now shows an explained fallback instead of an internal class name. diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt new file mode 100644 index 000000000..98a2eed57 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.IOException +import java.security.GeneralSecurityException +import okhttp3.Response + +/** + * Stable, non-secret classification of why contract acquisition could not complete. + * + * Callers derive product copy and diagnostics from this value. Exception messages and class + * names are local diagnostics only and never become a stable identifier. + */ +enum class ContractAcquisitionFailureKind { + /** No response arrived from a contract source: DNS, TLS, connection, or timeout failure. */ + Network, + + /** The App Store or an App Store-linked source answered with a non-success HTTP status. */ + SourceUnavailable, + + /** A package signature, certificate, identity, or source origin check rejected the source. */ + VerificationFailed, + + /** The server-reported app ID or version cannot select a release safely. */ + InvalidRequest, + + /** This runtime could not load or initialize acquisition code. */ + RuntimeIncompatible, + + /** Any other failure. */ + Unexpected, +} + +/** A contract source answered, but not with the requested resource. */ +class ContractSourceHttpException(val status: Int, message: String) : IllegalStateException(message) + +/** A package or source failed verification. The original failure stays available as the cause. */ +class ContractSourceVerificationException(message: String, cause: Throwable? = null) : + SecurityException(message, cause) + +/** The app ID or version supplied for release selection is malformed. */ +class ContractAcquisitionRequestException(message: String) : IllegalArgumentException(message) + +/** + * Classifies a failure thrown by [SignedAppStoreContractAcquirer.acquire]. + * + * Typed acquisition exceptions win over their supertypes. A [LinkageError] means acquisition code + * could not load or initialize on this runtime, for example a static initializer that the Android + * runtime rejects; it is reported as a client fault instead of a contract or network problem. + */ +fun classifyContractAcquisitionFailure(failure: Throwable): ContractAcquisitionFailureKind = when (failure) { + is ContractSourceHttpException -> ContractAcquisitionFailureKind.SourceUnavailable + is ContractSourceVerificationException -> ContractAcquisitionFailureKind.VerificationFailed + is ContractAcquisitionRequestException -> ContractAcquisitionFailureKind.InvalidRequest + is IOException -> ContractAcquisitionFailureKind.Network + is GeneralSecurityException -> ContractAcquisitionFailureKind.VerificationFailed + is LinkageError -> ContractAcquisitionFailureKind.RuntimeIncompatible + else -> ContractAcquisitionFailureKind.Unexpected +} + +internal inline fun requireValidRequest(valid: Boolean, message: () -> String) { + if (!valid) throw ContractAcquisitionRequestException(message()) +} + +internal inline fun requireVerifiedSource(verified: Boolean, message: () -> String) { + if (!verified) throw ContractSourceVerificationException(message()) +} + +internal inline fun requireSuccessfulSource(response: Response, message: () -> String) { + if (!response.isSuccessful) throw ContractSourceHttpException(response.code, message()) +} + +/** Package and identity checks throw several exception types; the acquisition boundary is one kind. */ +internal fun Exception.asSourceVerificationFailure(appId: String): ContractSourceVerificationException = + this as? ContractSourceVerificationException + ?: ContractSourceVerificationException("The signed $appId package was rejected.", this) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index d412447f9..0e5028b03 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -143,10 +143,10 @@ class SignedAppStoreContractAcquirer( } private fun acquireUncoalesced(request: ContractAcquisitionRequest): VerifiedOpenApiContract? { - require(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } + requireValidRequest(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } val coreServerVersion = normalizeServerVersion(request.serverVersion) request.installedAppVersion?.let { version -> - require(version.matches(APP_VERSION_PATTERN)) { "The installed app version is invalid." } + requireValidRequest(version.matches(APP_VERSION_PATTERN)) { "The installed app version is invalid." } } verifiedContracts[request]?.let { return it } verifiedContractCache.load(request)?.let { cached -> @@ -181,34 +181,34 @@ class SignedAppStoreContractAcquirer( val archive = archiveResponse.use { response -> requireSecureResponse(response) if (response.code in setOf(404, 410) && verifiedRouteFallbacks.isNotEmpty()) return@use null - check(response.isSuccessful) { + requireSuccessfulSource(response) { "Downloading the signed ${request.appId} package failed (HTTP ${response.code})." } response.readBodyLimited(MAX_ARCHIVE_BYTES) } ?: continue - try { - val verified = trustVerifier.verifyAndExtract(release, archive) - if (verified.contractKind == VerifiedContractKind.VerifiedReadRoutes) { - acquireGitHubTagContract( - release = release, - installedAppVersion = request.installedAppVersion, - exact = release.version == request.installedAppVersion, - )?.let { linkedContract -> - return cacheContract( - request, - linkedContract.mergeVerifiedPackageRoutes(verified), - ) - } - verifiedRouteFallbacks += release to verified - continue - } - return cacheContract( - request, - verified.asPackageContract(request, release), - ) + val verified = try { + trustVerifier.verifyAndExtract(release, archive) } catch (_: OpenApiContractMissingException) { missingPackageContracts += release + continue + } catch (failure: Exception) { + throw failure.asSourceVerificationFailure(request.appId) + } + if (verified.contractKind == VerifiedContractKind.VerifiedReadRoutes) { + acquireGitHubTagContract( + release = release, + installedAppVersion = request.installedAppVersion, + exact = release.version == request.installedAppVersion, + )?.let { linkedContract -> + return cacheContract( + request, + linkedContract.mergeVerifiedPackageRoutes(verified), + ) + } + verifiedRouteFallbacks += release to verified + continue } + return cacheContract(request, verified.asPackageContract(request, release)) } for (release in missingPackageContracts) { @@ -471,7 +471,7 @@ class SignedAppStoreContractAcquirer( val response = execute(Request.Builder().url(url).header("Accept", accept).get().build()) return response.use { githubResponse -> if (githubResponse.code == 404 || githubResponse.code in recoverableStatusCodes) return@use null - check(githubResponse.isSuccessful) { + requireSuccessfulSource(githubResponse) { "Loading an exact-tag GitHub contract resource failed (HTTP ${githubResponse.code})." } requireSecureResponse(githubResponse) @@ -495,7 +495,7 @@ class SignedAppStoreContractAcquirer( } val response = execute(Request.Builder().url(catalogUrl).get().build()) val bytes = response.use { catalogResponse -> - check(catalogResponse.isSuccessful) { + requireSuccessfulSource(catalogResponse) { "Loading the Nextcloud App Store catalog failed (HTTP ${catalogResponse.code})." } requireSecureResponse(catalogResponse) @@ -513,17 +513,17 @@ class SignedAppStoreContractAcquirer( private fun execute(request: Request): Response = httpClient.newCall(request).execute() private fun requireSecureUrl(url: String) { - if (requireHttps) require(url.startsWith("https://")) { "Contract sources must use HTTPS." } + if (requireHttps) requireVerifiedSource(url.startsWith("https://")) { "Contract sources must use HTTPS." } } private fun requireSecureResponse(response: Response) { - if (requireHttps) require(response.request.url.isHttps) { "A contract request was redirected away from HTTPS." } + if (requireHttps) requireVerifiedSource(response.request.url.isHttps) { "A contract request left HTTPS." } } private fun requireExpectedOrigin(response: Response, expectedBaseUrl: String) { val expected = URI(expectedBaseUrl) val actual = response.request.url - check( + requireVerifiedSource( actual.scheme.equals(expected.scheme, ignoreCase = true) && actual.host.equals(expected.host, ignoreCase = true) && actual.port == expected.effectivePort() @@ -644,10 +644,10 @@ private data class ParsedVersion( private fun normalizeServerVersion(version: String): String { val parsed = parseVersion(version) - require(parsed != null && parsed.numeric.size >= 3) { + requireValidRequest(parsed != null && parsed.numeric.size >= 3) { "The server version must contain at least three numeric components." } - return parsed.numeric.take(3).joinToString(".") + return requireNotNull(parsed).numeric.take(3).joinToString(".") } private fun parseVersion(version: String): ParsedVersion? { diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt new file mode 100644 index 000000000..ec9b0b189 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt @@ -0,0 +1,162 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.security.SignatureException +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class ContractAcquisitionFailureTest { + @Test + fun `typed acquisition exceptions win over their JVM supertypes`() { + assertKind(ContractAcquisitionFailureKind.SourceUnavailable, ContractSourceHttpException(503, "synthetic")) + assertKind(ContractAcquisitionFailureKind.VerificationFailed, ContractSourceVerificationException("synthetic")) + assertKind(ContractAcquisitionFailureKind.InvalidRequest, ContractAcquisitionRequestException("synthetic")) + assertKind(ContractAcquisitionFailureKind.Network, IOException("synthetic")) + assertKind(ContractAcquisitionFailureKind.Network, SocketTimeoutException("synthetic")) + assertKind(ContractAcquisitionFailureKind.Network, UnknownHostException("fixture.invalid")) + assertKind(ContractAcquisitionFailureKind.VerificationFailed, SignatureException("synthetic")) + } + + @Test + fun `runtime initialization failures are client faults and never read their class-name message`() { + val initializer = ExceptionInInitializerError(IllegalArgumentException("Syntax error near index 3")) + assertKind(ContractAcquisitionFailureKind.RuntimeIncompatible, initializer) + assertKind( + ContractAcquisitionFailureKind.RuntimeIncompatible, + NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.AppOwnedOpenApiContractKt"), + ) + assertKind(ContractAcquisitionFailureKind.RuntimeIncompatible, NoSuchMethodError("org.json.JSONObject.keySet")) + } + + @Test + fun `unknown and wrapped failures are not reclassified from causes or messages`() { + assertKind(ContractAcquisitionFailureKind.Unexpected, IllegalStateException("HTTP 503 network timeout")) + assertKind(ContractAcquisitionFailureKind.Unexpected, RuntimeException(IOException("synthetic"))) + assertKind(ContractAcquisitionFailureKind.Unexpected, StackOverflowError()) + } + + @Test + fun `malformed request values are invalid requests before any network access`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + for (request in listOf( + ContractAcquisitionRequest("../escape", "34.0.3", "1.0.0"), + ContractAcquisitionRequest("example", "34", "1.0.0"), + ContractAcquisitionRequest("example", "not-a-version", "1.0.0"), + ContractAcquisitionRequest("example", "34.0.3", "-1.0.0"), + )) { + assertKind(ContractAcquisitionFailureKind.InvalidRequest, assertFails(acquirer, request)) + } + assertEquals(0, server.requestCount) + } + } + + @Test + fun `catalog and package status failures are typed source failures with their status`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(code = 503, body = "synthetic")) + val catalog = assertIs(assertFails(acquirer(server), request())) + assertEquals(503, catalog.status) + } + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + server.enqueue(MockResponse(code = 500, body = "synthetic")) + val download = assertIs(assertFails(acquirer(server), request())) + assertEquals(500, download.status) + assertEquals(ContractAcquisitionFailureKind.SourceUnavailable, classifyContractAcquisitionFailure(download)) + } + } + + @Test + fun `rejected packages keep their cause and remain security failures`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + server.enqueue(MockResponse(body = "tampered")) + val rejection = IllegalStateException("The downloaded app package signature is invalid.") + val failure = assertIs( + assertFails(acquirer(server) { throw rejection }, request()), + ) + assertSame(rejection, failure.cause) + assertIs(failure) + assertKind(ContractAcquisitionFailureKind.VerificationFailed, failure) + } + } + + @Test + fun `an initializer failure inside package extraction escapes unwrapped for runtime classification`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + server.enqueue(MockResponse(body = "package")) + val initializer = ExceptionInInitializerError(IllegalArgumentException("synthetic")) + val failure = assertFailsWith { + acquirer(server) { throw initializer }.acquire(request()) + } + assertSame(initializer, failure) + assertKind(ContractAcquisitionFailureKind.RuntimeIncompatible, failure) + } + } + + @Test + fun `an unreachable catalog is a network failure`() { + val port = MockWebServer().use { server -> + server.start() + server.port + } + val acquirer = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient.Builder().connectTimeout(2, TimeUnit.SECONDS).build(), + appStoreBaseUrl = "http://127.0.0.1:$port/api/v1", + requireHttps = false, + ) + assertKind(ContractAcquisitionFailureKind.Network, assertFails(acquirer, request())) + } + + @Test + fun `a plain HTTP catalog is a verification failure when HTTPS is required`() { + val acquirer = SignedAppStoreContractAcquirer(appStoreBaseUrl = "http://fixture.invalid/api/v1") + val failure = assertFails(acquirer, request()) + assertIs(failure) + assertKind(ContractAcquisitionFailureKind.VerificationFailed, failure) + } + + private fun assertKind(expected: ContractAcquisitionFailureKind, failure: Throwable) { + assertEquals(expected, classifyContractAcquisitionFailure(failure), failure.javaClass.simpleName) + } + + private fun assertFails(acquirer: SignedAppStoreContractAcquirer, request: ContractAcquisitionRequest): Throwable = + assertFailsWith { acquirer.acquire(request) } + + private fun request() = ContractAcquisitionRequest("example", "34.0.3", "1.0.0") + + private fun catalog(server: MockWebServer): String { + val download = server.url("example-1.0.0.tar.gz") + return """[{"id":"example","certificate":"certificate","releases":[""" + + """{"version":"1.0.0","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""" + } + + private fun acquirer( + server: MockWebServer, + verify: (AppStoreRelease) -> VerifiedPackageContract = { error("The verifier must not run.") }, + ) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + ) +} diff --git a/tools/check-kotlin-architecture.sh b/tools/check-kotlin-architecture.sh index d07338219..29c3aec65 100755 --- a/tools/check-kotlin-architecture.sh +++ b/tools/check-kotlin-architecture.sh @@ -144,9 +144,11 @@ if [[ -d "$common_main" ]]; then fi fi -# JVM libraries consumed by Android also run on ICU, not the desktop regex engine. +# Android runs every one of these source sets on ICU, not the desktop regex engine. # Numeric quantifiers remain valid; literal brace templates need a deterministic parser. -for portable_source in "$project_root/ui/src/commonMain" "$project_root/ui/src/jvmMain" "$project_root/contractAcquisition/src/main"; do +for portable_source in \ + "$project_root/ui/src/commonMain" "$project_root/ui/src/jvmMain" "$project_root/ui/src/androidMain" \ + "$project_root/androidApp/src/main" "$project_root/contractAcquisition/src/main"; do [[ -d "$portable_source" ]] || continue if search_kotlin_lines '(Regex\(|\.toRegex\().*\\[{}]|\\[{}].*\.toRegex\(' "$portable_source"; then printf 'Parse brace templates without Regex in Android-consumed shared code.\n' >&2 diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index a875c8a79..ffa91a076 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,4 +1,4 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4132 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4105 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1212 @@ -9,7 +9,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|11 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|864 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1801 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2437 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2436 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2515 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1877 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11087 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11085 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1550 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716 @@ -48,7 +48,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6169 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6143 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1069 diff --git a/tools/test-kotlin-architecture.sh b/tools/test-kotlin-architecture.sh index e1faf6457..dedda1cde 100755 --- a/tools/test-kotlin-architecture.sh +++ b/tools/test-kotlin-architecture.sh @@ -70,6 +70,12 @@ private val code = Regex("[A-Za-z]{2}") EOF "$checker" "$fixture" >/dev/null rm "$fixture/contractAcquisition/src/main/kotlin/example/PortableHost.kt" +cat > "$fixture/androidApp/src/main/kotlin/example/AndroidHost.kt" <<'EOF' +package example +private val host = "\\{[A-Za-z]+}".toRegex() +EOF +expect_failure 'an ICU-incompatible brace regex in Android application code' "$checker" "$fixture" +rm "$fixture/androidApp/src/main/kotlin/example/AndroidHost.kt" cat > "$fixture/ui/src/commonMain/kotlin/example/PlatformImport.kt" <<'EOF' package example diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index fc9aab651..3d131ed6d 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -195,6 +195,8 @@ kotlin { kotlin.srcDir("src/jvmMain/kotlin") } androidMain.dependencies { + // Shared jvmMain sources translate contract acquisition failures for both JVM targets. + implementation(project(":contractAcquisition")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.exifinterface) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt new file mode 100644 index 000000000..fe81641c2 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -0,0 +1,126 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.serialization.Serializable + +/** Typed reason an App Store contract could not be acquired. Product copy derives from this value. */ +enum class AppStoreContractFailureKind { + /** The App Store or a linked source could not be reached. */ + Network, + + /** A contract source answered without the requested release or resource. */ + SourceUnavailable, + + /** A signature, certificate, package identity, or source origin check rejected the source. */ + VerificationFailed, + + /** The app ID or version reported by the server cannot select a release safely. */ + InvalidRequest, + + /** Contract acquisition code could not load or initialize on this runtime. */ + RuntimeIncompatible, + + /** Any other failure. */ + Unexpected, +} + +/** + * The only failure [NextcloudPlatformServices.acquireSignedOpenApiContract] reports. + * + * The message is a fixed token. The original failure remains available as [cause] for local + * diagnostics and never becomes product copy. + */ +class AppStoreContractAcquisitionException( + val kind: AppStoreContractFailureKind, + cause: Throwable? = null, +) : Exception("App Store contract acquisition failed: ${kind.name}.", cause) + +/** Why discovery fell back to app metadata. Verified and persisted contracts carry no reason. */ +@Serializable +enum class DynamicContractFallbackReason { + /** No same-origin or App Store source provides a verified contract for this version. */ + NoVerifiedContract, + VersionUnavailable, + AppStoreUnreachable, + AppStoreUnavailable, + VerificationFailed, + UnsupportedContract, + ClientFault, +} + +internal fun AppStoreContractFailureKind.fallbackReason(): DynamicContractFallbackReason = when (this) { + AppStoreContractFailureKind.Network -> DynamicContractFallbackReason.AppStoreUnreachable + AppStoreContractFailureKind.SourceUnavailable -> DynamicContractFallbackReason.AppStoreUnavailable + AppStoreContractFailureKind.VerificationFailed -> DynamicContractFallbackReason.VerificationFailed + AppStoreContractFailureKind.InvalidRequest -> DynamicContractFallbackReason.VersionUnavailable + AppStoreContractFailureKind.RuntimeIncompatible, + AppStoreContractFailureKind.Unexpected, + -> DynamicContractFallbackReason.ClientFault +} + +/** Classifies a failure from the platform acquisition boundary without reading exception text. */ +internal fun Throwable.appStoreContractFailureKind(): AppStoreContractFailureKind = + (this as? AppStoreContractAcquisitionException)?.kind ?: AppStoreContractFailureKind.Unexpected + +/** Keeps the metadata fallback and records why no verified contract replaced it. */ +internal fun DynamicDescriptorDiscovery.withFallback( + reason: DynamicContractFallbackReason, + diagnostic: String, +): DynamicDescriptorDiscovery = copy(diagnostics = diagnostics + diagnostic, fallbackReason = reason) + +/** Shown when discovery itself fails before producing even a metadata fallback. */ +internal const val DYNAMIC_DISCOVERY_FAILURE_MESSAGE = "Could not check this app's native API. Try again." + +internal data class DynamicContractFallbackNotice( + val title: String, + val message: String, + val actionLabel: String, +) + +/** Product copy for a metadata fallback. It explains what happened and whether checking again can help. */ +internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFallbackNotice = when (this) { + null -> DynamicContractFallbackNotice( + "App unavailable", + "This app's content could not be loaded. Try again.", + "Try again", + ) + DynamicContractFallbackReason.NoVerifiedContract -> DynamicContractFallbackNotice( + "No verified native API", + "Neither the server nor the signed App Store release for this app version provides an API " + + "contract or safe read routes. Only app details are shown.", + "Check again", + ) + DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( + "App version not confirmed", + "The server or app version could not be confirmed, so no matching signed App Store release " + + "could be selected. Check the connection and try again.", + "Try again", + ) + DynamicContractFallbackReason.AppStoreUnreachable -> DynamicContractFallbackNotice( + "App Store not reachable", + "The Nextcloud App Store could not be reached to verify this app's API. " + + "Check the connection and try again.", + "Try again", + ) + DynamicContractFallbackReason.AppStoreUnavailable -> DynamicContractFallbackNotice( + "App Store release unavailable", + "The Nextcloud App Store or the app's linked source did not provide this release. Try again later.", + "Try again", + ) + DynamicContractFallbackReason.VerificationFailed -> DynamicContractFallbackNotice( + "App package not verified", + "The signed App Store release for this app could not be verified, so no native view is offered. " + + "Only app details are shown.", + "Check again", + ) + DynamicContractFallbackReason.UnsupportedContract -> DynamicContractFallbackNotice( + "API contract not supported", + "This app's verified API contract cannot be shown as a native view yet. Only app details are shown.", + "Check again", + ) + DynamicContractFallbackReason.ClientFault -> DynamicContractFallbackNotice( + "Contract check failed", + "nati.ve could not complete the API contract check on this device. " + + "Try again, and update nati.ve if this continues.", + "Try again", + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 7183a0573..73f68a9a9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -70,6 +70,7 @@ data class DynamicDescriptorDiscovery( val acquisition: DynamicDescriptorAcquisition, val diagnostics: List = emptyList(), val versionStatus: DynamicContractVersionStatus = DynamicContractVersionStatus.VerifiedCurrent, + val fallbackReason: DynamicContractFallbackReason? = null, ) @Serializable @@ -131,11 +132,10 @@ suspend fun discoverDynamicAppDescriptor( onProgress = onProgress, ) if (sameOrigin.acquisition != DynamicDescriptorAcquisition.MetadataFallback) return sameOrigin - val coreVersion = serverVersion?.coreVersionOrNull() - ?: return sameOrigin.copy( - diagnostics = sameOrigin.diagnostics + - "The server version is unavailable, so an App Store release could not be selected safely.", - ) + val coreVersion = serverVersion?.coreVersionOrNull() ?: return sameOrigin.withFallback( + DynamicContractFallbackReason.VersionUnavailable, + "The server version is unavailable, so an App Store release could not be selected safely.", + ) val contractAppId = app.canonicalAppStoreId() onProgress( DynamicDescriptorDiscoveryProgress( @@ -159,21 +159,21 @@ suspend fun discoverDynamicAppDescriptor( val acquired = observeDynamicDiscoveryStage(DynamicDiscoveryStage.PackageAcquisition, services::recordSupportDiagnostic) { services.acquireSignedOpenApiContract(contractAppId, coreVersion, installedVersion) }.getOrElse { failure -> - return sameOrigin.copy( - diagnostics = sameOrigin.diagnostics + - "App Store contract acquisition failed: ${failure.message ?: "verification failed"}.", - ) - } ?: return sameOrigin.copy( - diagnostics = sameOrigin.diagnostics + - "No exact or patch-compatible App Store source yielded a usable API contract or " + + // Only the typed kind enters diagnostics and copy; exception text and class names stay local. + val kind = failure.appStoreContractFailureKind() + return sameOrigin.withFallback(kind.fallbackReason(), "App Store contract acquisition failed: ${kind.name}.") + } ?: return sameOrigin.withFallback( + DynamicContractFallbackReason.NoVerifiedContract, + "No exact or patch-compatible App Store source yielded a usable API contract or " + "verified static read routes for $contractAppId on Nextcloud $coreVersion. " + "Only app metadata is available.", ) val versionStatus = acquired.effectiveDynamicContractVersionStatus(observedVersionStatus) val document = observeDynamicDiscoveryStage(DynamicDiscoveryStage.ContractParsing, services::recordSupportDiagnostic) { dynamicJson.parseToJsonElement(acquired.document) as? JsonObject - }.getOrNull() ?: return sameOrigin.copy( - diagnostics = sameOrigin.diagnostics + "The acquired App Store contract contained invalid JSON.", + }.getOrNull() ?: return sameOrigin.withFallback( + DynamicContractFallbackReason.UnsupportedContract, + "The acquired App Store contract contained invalid JSON.", ) val verifiedReadRouteCount = (document["x-nextcloud-native-verified-read-route-count"] as? JsonPrimitive) ?.contentOrNull @@ -218,11 +218,10 @@ suspend fun discoverDynamicAppDescriptor( ), ), ).requireValid() - }.getOrElse { failure -> - return sameOrigin.copy( - diagnostics = sameOrigin.diagnostics + - "The acquired App Store contract could not be compiled into a native read surface: " + - "${failure.message ?: "unsupported contract"}.", + }.getOrElse { + return sameOrigin.withFallback( + DynamicContractFallbackReason.UnsupportedContract, + "The acquired App Store contract could not be compiled into a native read surface.", ) } return DynamicDescriptorDiscovery( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3e5d61c50..e22a188c0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -2218,7 +2218,7 @@ private fun AppInfoScreen( } sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id, cacheProducer) discoveryError = if (retainedDiscovery == null) { - failure.message ?: "Could not discover this app's native API." + DYNAMIC_DISCOVERY_FAILURE_MESSAGE } else { "Could not verify the current server and app versions. " + "The last verified contract remains available in read-only mode." @@ -4651,13 +4651,11 @@ private fun DynamicDiscoveredAppScreen( modifier = Modifier.padding(NextcloudSpacing.Medium), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), ) { - Text("App unavailable", style = MaterialTheme.typography.titleSmall) - Text( - "This app's content could not be loaded. Try again.", - style = MaterialTheme.typography.bodySmall, - ) + val notice = discovery.fallbackReason.fallbackNotice() + Text(notice.title, style = MaterialTheme.typography.titleSmall) + Text(notice.message, style = MaterialTheme.typography.bodySmall) TextButton(onClick = onRetryDiscovery) { - Text("Try again") + Text(notice.actionLabel) } } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 50184f8d4..2eac20dce 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3,11 +3,9 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache -import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache import dev.obiente.nextcloudnative.contracts.FileVerifiedContractCache import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer -import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.ByteArrayInputStream @@ -5044,34 +5042,10 @@ class DesktopNextcloudServices( serverVersion: String, installedAppVersion: String?, ): AcquiredOpenApiContract? = withContext(Dispatchers.IO) { - contractAcquirer.acquire(ContractAcquisitionRequest(appId, serverVersion, installedAppVersion)) - ?.let { contract -> - AcquiredOpenApiContract( - appId = contract.appId, - appVersion = contract.appVersion, - contractVersion = contract.contractVersion, - specFile = contract.specFile, - document = contract.document, - packageUrl = contract.packageUrl, - sourceUrl = contract.sourceUrl, - sourceKind = when (contract.sourceKind) { - OpenApiContractSourceKind.SignedAppPackage -> - AcquiredOpenApiContractSourceKind.SignedAppPackage - OpenApiContractSourceKind.SignedCompatibleAppPackage -> - AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage - OpenApiContractSourceKind.AppStoreLinkedExactGitHubTag -> - AcquiredOpenApiContractSourceKind.AppStoreLinkedExactGitHubTag - OpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag -> - AcquiredOpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag - }, - contractKind = when (contract.contractKind) { - VerifiedContractKind.OpenApi -> AcquiredContractKind.OpenApi - VerifiedContractKind.VerifiedReadRoutes -> AcquiredContractKind.VerifiedReadRoutes - VerifiedContractKind.OpenApiWithVerifiedReadRoutes -> - AcquiredContractKind.OpenApiWithVerifiedReadRoutes - }, - ) - } + acquireAppStoreContract( + ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), + contractAcquirer::acquire, + ) } override suspend fun listActivities(session: NextcloudSession, limit: Int): List = diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt new file mode 100644 index 000000000..1568347e4 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -0,0 +1,144 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DynamicContractFallbackDiscoveryTest { + private val session = NextcloudSession("https://fixture.invalid", "fixture", "synthetic") + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + @Test + fun everyTypedAcquisitionFailureBecomesAnExplainedMetadataFallback() = runBlocking { + val expected = mapOf( + AppStoreContractFailureKind.Network to DynamicContractFallbackReason.AppStoreUnreachable, + AppStoreContractFailureKind.SourceUnavailable to DynamicContractFallbackReason.AppStoreUnavailable, + AppStoreContractFailureKind.VerificationFailed to DynamicContractFallbackReason.VerificationFailed, + AppStoreContractFailureKind.InvalidRequest to DynamicContractFallbackReason.VersionUnavailable, + AppStoreContractFailureKind.RuntimeIncompatible to DynamicContractFallbackReason.ClientFault, + AppStoreContractFailureKind.Unexpected to DynamicContractFallbackReason.ClientFault, + ) + assertEquals(AppStoreContractFailureKind.entries.toSet(), expected.keys) + for ((kind, reason) in expected) { + val cause = NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.SyntheticKt") + val discovery = discover { throw AppStoreContractAcquisitionException(kind, cause) } + assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition) + assertEquals(reason, discovery.fallbackReason) + assertEquals("App Store contract acquisition failed: ${kind.name}.", discovery.diagnostics.last()) + assertNoPrivateText(discovery) + assertTrue("app-store-acquisition-failed" in discovery.toContractInfo(null).diagnosticCodes) + } + } + + @Test + fun untypedPlatformFailuresAreClientFaultsWithoutTheirMessage() = runBlocking { + val discovery = discover { + throw IllegalStateException("dev.obiente.nextcloudnative.contracts.SyntheticKt https://private.invalid/?token=secret") + } + assertEquals(DynamicContractFallbackReason.ClientFault, discovery.fallbackReason) + assertNoPrivateText(discovery) + } + + @Test + fun missingContractUnavailableVersionAndInvalidContractEachHaveTheirOwnReason() = runBlocking { + assertEquals(DynamicContractFallbackReason.NoVerifiedContract, discover { null }.fallbackReason) + var acquisitions = 0 + val noVersion = discover(serverVersion = null) { acquisitions += 1; null } + assertEquals(DynamicContractFallbackReason.VersionUnavailable, noVersion.fallbackReason) + assertEquals(0, acquisitions) + for (document in listOf("not json", "[]", """{"openapi":"3.0.3","paths":{"/apps/example/items":"""")) { + val invalid = discover { contract(document) } + assertEquals(DynamicContractFallbackReason.UnsupportedContract, invalid.fallbackReason) + assertNoPrivateText(invalid) + } + } + + @Test + fun cancellationDuringAcquisitionIsNotConvertedIntoAFallback() = runBlocking { + val cancelled = CancellationException("stop") + assertSame(cancelled, assertFailsWith { discover { throw cancelled } }) + } + + @Test + fun fallbackReasonIsNeverPersistedAndLegacyEntriesDecodeWithoutIt() = runBlocking { + val fallback = discover { null } + assertNull(encodePersistedDynamicDiscovery(fallback)) + val verified = fallback.copy(acquisition = DynamicDescriptorAcquisition.StaticAppAsset, fallbackReason = null) + // Entries written before the field existed have the same shape as entries without a reason. + val encoded = requireNotNull(encodePersistedDynamicDiscovery(verified)) + assertFalse("fallbackReason" in encoded) + val restored = requireNotNull(decodePersistedDynamicDiscovery(encoded, app.id, session.serverUrl)) + assertNull(restored.fallbackReason) + } + + @Test + fun everyFallbackReasonExplainsWhatHappenedInDistinctAsciiCopy() { + val notices = (DynamicContractFallbackReason.entries + null).map { it.fallbackNotice() } + assertEquals(notices.size, notices.map { it.title }.distinct().size) + assertEquals(notices.size, notices.map { it.message }.distinct().size) + for (notice in notices + DynamicContractFallbackNotice(DYNAMIC_DISCOVERY_FAILURE_MESSAGE, "", "")) { + val text = notice.title + notice.message + notice.actionLabel + assertTrue(text.all { it.code in 0x20..0x7e }, text) + assertFalse("Kt" in text || "Exception" in text || "Error" in text, text) + } + assertEquals("Check again", DynamicContractFallbackReason.NoVerifiedContract.fallbackNotice().actionLabel) + assertEquals("Try again", DynamicContractFallbackReason.AppStoreUnreachable.fallbackNotice().actionLabel) + } + + private fun assertNoPrivateText(discovery: DynamicDescriptorDiscovery) { + val text = discovery.diagnostics.joinToString("\n") + for (secret in listOf("SyntheticKt", "private.invalid", "token", "NoClassDefFoundError", "IllegalState")) { + assertFalse(secret in text, "Discovery diagnostics exposed $secret") + } + } + + private suspend fun discover( + serverVersion: String? = "34.0.3", + acquire: suspend () -> AcquiredOpenApiContract?, + ): DynamicDescriptorDiscovery = discoverDynamicAppDescriptor( + services = services(acquire), + session = session, + app = app, + serverVersion = serverVersion, + installedAppVersionHint = "1.0.0", + ) + + private fun contract(document: String) = AcquiredOpenApiContract( + appId = "example", + appVersion = "1.0.0", + contractVersion = "1.0.0", + specFile = "openapi.json", + document = document, + packageUrl = "https://fixture.invalid/example-1.0.0.tar.gz", + sourceUrl = "https://fixture.invalid/example-1.0.0.tar.gz#openapi.json", + sourceKind = AcquiredOpenApiContractSourceKind.SignedAppPackage, + ) + + /** Only same-origin probes and the App Store boundary are available; every probe is a miss. */ + private fun services(acquire: suspend () -> AcquiredOpenApiContract?): NextcloudPlatformServices { + val unavailable = Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { _, method, _ -> if (method.name == "recordSupportDiagnostic") Unit else error("Unexpected ${method.name}") } + as NextcloudPlatformServices + return object : NextcloudPlatformServices by unavailable { + override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = Unit + + override suspend fun executeNextcloudApi(session: NextcloudSession, request: NextcloudApiRequest) = + NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) + + override suspend fun acquireSignedOpenApiContract( + appId: String, + serverVersion: String, + installedAppVersion: String?, + ): AcquiredOpenApiContract? = acquire() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt new file mode 100644 index 000000000..e1186549c --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -0,0 +1,101 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequestException +import dev.obiente.nextcloudnative.contracts.ContractSourceHttpException +import dev.obiente.nextcloudnative.contracts.ContractSourceVerificationException +import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind +import dev.obiente.nextcloudnative.contracts.VerifiedContractKind +import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract +import java.net.SocketTimeoutException +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame + +class JvmAppStoreContractAcquisitionTest { + private val request = ContractAcquisitionRequest("example", "34.0.3", "1.0.0") + + @Test + fun verifiedContractKeepsItsProvenanceAndKind() { + for ((source, expectedSource) in listOf( + OpenApiContractSourceKind.SignedAppPackage to AcquiredOpenApiContractSourceKind.SignedAppPackage, + OpenApiContractSourceKind.SignedCompatibleAppPackage to + AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage, + OpenApiContractSourceKind.AppStoreLinkedExactGitHubTag to + AcquiredOpenApiContractSourceKind.AppStoreLinkedExactGitHubTag, + OpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag to + AcquiredOpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag, + )) { + for ((kind, expectedKind) in listOf( + VerifiedContractKind.OpenApi to AcquiredContractKind.OpenApi, + VerifiedContractKind.VerifiedReadRoutes to AcquiredContractKind.VerifiedReadRoutes, + VerifiedContractKind.OpenApiWithVerifiedReadRoutes to AcquiredContractKind.OpenApiWithVerifiedReadRoutes, + )) { + val acquired = requireNotNull(acquireAppStoreContract(request) { verified(source, kind) }) + assertEquals(expectedSource, acquired.sourceKind) + assertEquals(expectedKind, acquired.contractKind) + assertEquals("1.0.1", acquired.appVersion) + assertEquals("1.0.0", acquired.contractVersion) + assertEquals("https://fixture.invalid/example.tar.gz#openapi.json", acquired.sourceUrl) + } + } + assertNull(acquireAppStoreContract(request) { null }) + } + + @Test + fun acquisitionFailuresBecomeTypedFailuresWithTheirOriginalCause() { + for ((failure, expected) in listOf( + SocketTimeoutException("synthetic") to AppStoreContractFailureKind.Network, + ContractSourceHttpException(503, "synthetic") to AppStoreContractFailureKind.SourceUnavailable, + ContractSourceVerificationException("synthetic") to AppStoreContractFailureKind.VerificationFailed, + ContractAcquisitionRequestException("synthetic") to AppStoreContractFailureKind.InvalidRequest, + IllegalStateException("synthetic") to AppStoreContractFailureKind.Unexpected, + )) { + val typed = assertFailsWith { + acquireAppStoreContract(request) { throw failure } + } + assertEquals(expected, typed.kind) + assertSame(failure, typed.cause) + } + } + + @Test + fun runtimeLinkageFailuresAreTypedWithoutExposingTheClassName() { + for (failure in listOf( + NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.AppOwnedOpenApiContractKt"), + ExceptionInInitializerError(IllegalArgumentException("Syntax error near index 3")), + )) { + val typed = assertFailsWith { + acquireAppStoreContract(request) { throw failure } + } + assertEquals(AppStoreContractFailureKind.RuntimeIncompatible, typed.kind) + assertSame(failure, typed.cause) + assertFalse("Kt" in typed.message.orEmpty() || "Syntax" in typed.message.orEmpty()) + } + } + + @Test + fun cancellationAndUnrelatedErrorsAreNotConverted() { + val cancelled = CancellationException("stop") + assertSame(cancelled, assertFailsWith { acquireAppStoreContract(request) { throw cancelled } }) + val overflow = StackOverflowError() + assertSame(overflow, assertFailsWith { acquireAppStoreContract(request) { throw overflow } }) + } + + private fun verified(source: OpenApiContractSourceKind, kind: VerifiedContractKind) = VerifiedOpenApiContract( + appId = "example", + appVersion = "1.0.1", + contractVersion = "1.0.0", + specFile = "openapi.json", + document = """{"openapi":"3.0.3","paths":{}}""", + catalogUrl = "https://fixture.invalid/apps.json", + packageUrl = "https://fixture.invalid/example.tar.gz", + sourceUrl = "https://fixture.invalid/example.tar.gz#openapi.json", + sourceKind = source, + contractKind = kind, + ) +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt new file mode 100644 index 000000000..cf84a03f1 --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -0,0 +1,66 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionFailureKind +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind +import dev.obiente.nextcloudnative.contracts.VerifiedContractKind +import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract +import dev.obiente.nextcloudnative.contracts.classifyContractAcquisitionFailure +import kotlinx.coroutines.CancellationException + +/** + * Shared Android and desktop boundary between the contract acquisition module and discovery. + * + * Acquisition failures are translated once into [AppStoreContractAcquisitionException]. A + * [LinkageError] is caught here deliberately: acquisition code that cannot load or initialize on + * this runtime must become an honest metadata fallback instead of escaping with a class name as + * its message. Other errors, such as running out of memory, still propagate. + */ +fun acquireAppStoreContract( + request: ContractAcquisitionRequest, + acquire: (ContractAcquisitionRequest) -> VerifiedOpenApiContract?, +): AcquiredOpenApiContract? { + val contract = try { + acquire(request) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AppStoreContractAcquisitionException(classifyContractAcquisitionFailure(failure).toAppStoreKind(), failure) + } catch (failure: LinkageError) { + throw AppStoreContractAcquisitionException(AppStoreContractFailureKind.RuntimeIncompatible, failure) + } + return contract?.toAcquiredOpenApiContract() +} + +private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFailureKind = when (this) { + ContractAcquisitionFailureKind.Network -> AppStoreContractFailureKind.Network + ContractAcquisitionFailureKind.SourceUnavailable -> AppStoreContractFailureKind.SourceUnavailable + ContractAcquisitionFailureKind.VerificationFailed -> AppStoreContractFailureKind.VerificationFailed + ContractAcquisitionFailureKind.InvalidRequest -> AppStoreContractFailureKind.InvalidRequest + ContractAcquisitionFailureKind.RuntimeIncompatible -> AppStoreContractFailureKind.RuntimeIncompatible + ContractAcquisitionFailureKind.Unexpected -> AppStoreContractFailureKind.Unexpected +} + +private fun VerifiedOpenApiContract.toAcquiredOpenApiContract(): AcquiredOpenApiContract = AcquiredOpenApiContract( + appId = appId, + appVersion = appVersion, + contractVersion = contractVersion, + specFile = specFile, + document = document, + packageUrl = packageUrl, + sourceUrl = sourceUrl, + sourceKind = when (sourceKind) { + OpenApiContractSourceKind.SignedAppPackage -> AcquiredOpenApiContractSourceKind.SignedAppPackage + OpenApiContractSourceKind.SignedCompatibleAppPackage -> + AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage + OpenApiContractSourceKind.AppStoreLinkedExactGitHubTag -> + AcquiredOpenApiContractSourceKind.AppStoreLinkedExactGitHubTag + OpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag -> + AcquiredOpenApiContractSourceKind.AppStoreLinkedCompatibleGitHubTag + }, + contractKind = when (contractKind) { + VerifiedContractKind.OpenApi -> AcquiredContractKind.OpenApi + VerifiedContractKind.VerifiedReadRoutes -> AcquiredContractKind.VerifiedReadRoutes + VerifiedContractKind.OpenApiWithVerifiedReadRoutes -> AcquiredContractKind.OpenApiWithVerifiedReadRoutes + }, +) From 82153d6ea62017db9a024027a98a55c6192d5a37 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:39:38 +0000 Subject: [PATCH 02/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 5976f9d17..1c50a1bba 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -80,6 +80,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt", @@ -508,7 +509,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "0092b8bc6445328384db348a4fd5eb173c5fe4133d170a193fa7c399dcd411ab", - "ui/build.gradle.kts": "e36e00d547f1981e7dae3441102faa9bad3310ea5e0feda90b3c355b154ee1b2", + "ui/build.gradle.kts": "28902de4534db3a61b1259b773747ac0b21400ce901fabe906427d9abb032d3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPlaybackRetirement.kt": "01069d5a599bbf9dce5c9a152bb771f4bd524de7fb2d74db9c6bf9b774b31cb2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "d247d7d310e8579717e243bce827e8ab6d8a8e5a6df011b481e9bc6fc72da862", @@ -577,6 +578,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "b72145ed196a58b2b64a949c2bc72e9b9e724b39737a54f9c26c147d31abaaf5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "b8eafe05a2db826463e1319f75b0eac900bc6b00a59468702e90870e262b87f0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", @@ -587,7 +589,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "1b60dcf20f1bc8a1cbb4715db23afc8e5cbb8b5724fdf0157b9c2ec13d10b815", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "66ffaa40595e9fcea94e2d3f97daa20cce2951a72c372cf90d91bb363f3dd52b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", @@ -736,7 +738,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "526914a022c8785935bfedde38d6df93deab0c3736e81f4612f893153d76463d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "ead0f808c7ab8f82234e5aed75cbde8e6f42f7364e0934a07e0c60a09cec903c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", From 7a556c434f8ad8c739df928fb33293da593f2265 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:07:33 +0200 Subject: [PATCH 03/49] fix(contracts): keep cancellation and cache writes out of failure kinds Return cancellation unchanged from the package verification wrapper so the acquisition boundary still recognizes it as control flow. Publish a downloaded App Store catalog to the local cache on a best effort basis, matching verified-contract caching. A read-only cache directory or a full disk no longer fails an acquisition that already has the catalog, and is never reported as an unreachable App Store. --- DYNAMIC_APP_DESCRIPTOR.md | 8 ++- .../contracts/AppStoreCatalogCache.kt | 18 ++++++ .../contracts/ContractAcquisitionFailure.kt | 14 +++-- .../SignedAppStoreContractAcquirer.kt | 2 +- .../ContractAcquisitionFailureTest.kt | 62 +++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 221b5029c..bf1a9b786 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -194,7 +194,8 @@ The fallback card derives its title, explanation, and action from that reason: [`ContractAcquisitionFailure.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt) classifies the module's own failures: HTTP status responses, verification and origin rejections, malformed request values, network failures, and runtime -linkage errors. +linkage errors. Writing a downloaded catalog to the local cache is best effort, +so a local storage failure is never reported as an unreachable App Store. [`JvmAppStoreContractAcquisition.kt`](ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt) translates them once for both JVM targets. It also catches `LinkageError`. If acquisition code cannot initialize on a runtime, for example because the @@ -239,8 +240,9 @@ bodies, invalid text, and deeply nested JSON. - Acquisition rejects concrete package-server authorities because it cannot prove the connected account owns them. - Host placeholders are parsed without a brace regular expression. The `ui` - build checks `commonMain`, and `tools/check-kotlin-architecture.sh` checks - every source set that runs on Android. An Android instrumented test + build checks `commonMain`. `tools/check-kotlin-architecture.sh` checks the + shared `ui` source sets compiled for Android, the contract module, and every + `androidApp` source set except JVM unit tests. An Android instrumented test initializes every top-level contract-module class and exercises host rejection on the Android runtime. - Path and operation server overrides must pass the same origin checks and diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt index 15b5488d7..c6b422077 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative.contracts import java.io.File import java.security.MessageDigest +import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap interface AppStoreCatalogCache { @@ -9,6 +10,23 @@ interface AppStoreCatalogCache { fun store(url: String, bytes: ByteArray) } +/** + * Publishes a downloaded catalog without letting local storage decide the acquisition result. + * + * The catalog bytes are already in memory, so a read-only cache directory or a full disk only + * costs a later re-download. Like verified-contract caching, publication is best effort; a local + * write failure must never be reported as an unreachable App Store. + */ +internal fun AppStoreCatalogCache.storeBestEffort(url: String, bytes: ByteArray) { + try { + store(url, bytes) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The next acquisition downloads the catalog again. + } +} + class MemoryAppStoreCatalogCache : AppStoreCatalogCache { private val entries = ConcurrentHashMap() diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt index 98a2eed57..9fa63bf73 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative.contracts import java.io.IOException import java.security.GeneralSecurityException +import java.util.concurrent.CancellationException import okhttp3.Response /** @@ -69,7 +70,12 @@ internal inline fun requireSuccessfulSource(response: Response, message: () -> S if (!response.isSuccessful) throw ContractSourceHttpException(response.code, message()) } -/** Package and identity checks throw several exception types; the acquisition boundary is one kind. */ -internal fun Exception.asSourceVerificationFailure(appId: String): ContractSourceVerificationException = - this as? ContractSourceVerificationException - ?: ContractSourceVerificationException("The signed $appId package was rejected.", this) +/** + * Package and identity checks throw several exception types; the acquisition boundary is one kind. + * Cancellation is control flow, never a verification result, so it is returned unchanged. + */ +internal fun Exception.asSourceVerificationFailure(appId: String): RuntimeException = when (this) { + is CancellationException -> this + is ContractSourceVerificationException -> this + else -> ContractSourceVerificationException("The signed $appId package was rejected.", this) +} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 0e5028b03..9d165feec 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -501,7 +501,7 @@ class SignedAppStoreContractAcquirer( requireSecureResponse(catalogResponse) catalogResponse.readBodyLimited(MAX_CATALOG_BYTES) } - catalogCache.store(catalogUrl, bytes) + catalogCache.storeBestEffort(catalogUrl, bytes) return parseReleases( bytes.decodeToString(), appId, diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt index ec9b0b189..99c40be09 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailureTest.kt @@ -1,14 +1,18 @@ package dev.obiente.nextcloudnative.contracts +import java.io.File import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.security.SignatureException +import java.nio.file.Files +import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertSame import mockwebserver3.MockResponse import mockwebserver3.MockWebServer @@ -95,6 +99,62 @@ class ContractAcquisitionFailureTest { } } + @Test + fun `cancellation during package verification propagates unchanged`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + server.enqueue(MockResponse(body = "package")) + val cancelled = CancellationException("stop") + val failure = assertFailsWith { + acquirer(server) { throw cancelled }.acquire(request()) + } + assertSame(cancelled, failure) + } + } + + @Test + fun `a local catalog cache write failure does not fail a downloaded acquisition`() { + val diskFull = object : AppStoreCatalogCache { + override fun load(url: String): ByteArray? = null + override fun store(url: String, bytes: ByteArray) = throw IOException("No space left on device") + } + val blocker = Files.createTempFile("catalog-cache-blocker", ".tmp").toFile() + try { + // A regular file where the cache directory belongs makes real cache publication fail. + for (cache in listOf(diskFull, FileAppStoreCatalogCache(File(blocker, "cache")))) { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + server.enqueue(MockResponse(body = "package")) + val contract = assertNotNull(acquirer(server, catalogCache = cache) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }.acquire(request())) + assertEquals(OpenApiContractSourceKind.SignedAppPackage, contract.sourceKind) + assertEquals(2, server.requestCount) + } + } + } finally { + blocker.delete() + } + } + + @Test + fun `cancellation while publishing the catalog cache is not swallowed`() { + val cancelled = CancellationException("stop") + val cache = object : AppStoreCatalogCache { + override fun load(url: String): ByteArray? = null + override fun store(url: String, bytes: ByteArray) = throw cancelled + } + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server))) + assertSame(cancelled, assertFailsWith { + acquirer(server, catalogCache = cache).acquire(request()) + }) + } + } + @Test fun `an initializer failure inside package extraction escapes unwrapped for runtime classification`() { MockWebServer().use { server -> @@ -150,6 +210,7 @@ class ContractAcquisitionFailureTest { private fun acquirer( server: MockWebServer, + catalogCache: AppStoreCatalogCache = MemoryAppStoreCatalogCache(), verify: (AppStoreRelease) -> VerifiedPackageContract = { error("The verifier must not run.") }, ) = SignedAppStoreContractAcquirer( httpClient = OkHttpClient(), @@ -158,5 +219,6 @@ class ContractAcquisitionFailureTest { trustVerifier = object : AppPackageTrustVerifier { override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) }, + catalogCache = catalogCache, ) } From bd5fb72bc3ac7d4987f8f48092f87134aeed8ecd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:07:33 +0200 Subject: [PATCH 04/49] fix(tools): scan every Android application source set for brace regexes Discover androidApp source sets instead of listing only main, so debug, release, directApk, and instrumented test sources are checked too. JVM unit test sources stay excluded because they never run on ICU. --- tools/check-kotlin-architecture.sh | 8 +++++++- tools/test-kotlin-architecture.sh | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tools/check-kotlin-architecture.sh b/tools/check-kotlin-architecture.sh index 29c3aec65..9831034a8 100755 --- a/tools/check-kotlin-architecture.sh +++ b/tools/check-kotlin-architecture.sh @@ -146,9 +146,15 @@ fi # Android runs every one of these source sets on ICU, not the desktop regex engine. # Numeric quantifiers remain valid; literal brace templates need a deterministic parser. +# Every androidApp source set except JVM unit tests is discovered, so build variants +# such as debug, release, and directApk cannot be missed. +android_portable_sources=() +for android_source in "$project_root"/androidApp/src/*/; do + [[ "$(basename "$android_source")" == test ]] || android_portable_sources+=("${android_source%/}") +done for portable_source in \ "$project_root/ui/src/commonMain" "$project_root/ui/src/jvmMain" "$project_root/ui/src/androidMain" \ - "$project_root/androidApp/src/main" "$project_root/contractAcquisition/src/main"; do + "${android_portable_sources[@]}" "$project_root/contractAcquisition/src/main"; do [[ -d "$portable_source" ]] || continue if search_kotlin_lines '(Regex\(|\.toRegex\().*\\[{}]|\\[{}].*\.toRegex\(' "$portable_source"; then printf 'Parse brace templates without Regex in Android-consumed shared code.\n' >&2 diff --git a/tools/test-kotlin-architecture.sh b/tools/test-kotlin-architecture.sh index dedda1cde..0ffb5d0c4 100755 --- a/tools/test-kotlin-architecture.sh +++ b/tools/test-kotlin-architecture.sh @@ -76,6 +76,24 @@ private val host = "\\{[A-Za-z]+}".toRegex() EOF expect_failure 'an ICU-incompatible brace regex in Android application code' "$checker" "$fixture" rm "$fixture/androidApp/src/main/kotlin/example/AndroidHost.kt" +for variant in debug release directApk androidTest; do + mkdir -p "$fixture/androidApp/src/$variant/kotlin/example" + cat > "$fixture/androidApp/src/$variant/kotlin/example/VariantHost.kt" <<'EOF' +package example +private val host = Regex("\\{[A-Za-z]+}") +EOF + expect_failure "an ICU-incompatible brace regex in the $variant source set" "$checker" "$fixture" + expect_failure "brace regex detection in the $variant source set without ripgrep" \ + env KOTLIN_ARCHITECTURE_FORCE_PORTABLE_SEARCH=true "$checker" "$fixture" + rm -r "$fixture/androidApp/src/$variant" +done +mkdir -p "$fixture/androidApp/src/test/kotlin/example" +cat > "$fixture/androidApp/src/test/kotlin/example/JvmUnitHost.kt" <<'EOF' +package example +private val host = Regex("\\{[A-Za-z]+}") +EOF +"$checker" "$fixture" >/dev/null +rm -r "$fixture/androidApp/src/test" cat > "$fixture/ui/src/commonMain/kotlin/example/PlatformImport.kt" <<'EOF' package example From fbd14d269f2220aa9828bb8b656d327d75ba1cf0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 15 Oct 2026 03:09:04 +0200 Subject: [PATCH 05/49] refactor(contracts): move acquisition models into their own file --- .../contracts/ContractAcquisitionModels.kt | 65 +++++++++++++++++++ .../SignedAppStoreContractAcquirer.kt | 64 ------------------ tools/kotlin-file-size-baseline.txt | 2 +- 3 files changed, 66 insertions(+), 65 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt new file mode 100644 index 000000000..8ad4b6a95 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt @@ -0,0 +1,65 @@ +package dev.obiente.nextcloudnative.contracts + +data class ContractAcquisitionRequest( + val appId: String, + val serverVersion: String, + val installedAppVersion: String? = null, +) + +data class VerifiedOpenApiContract( + val appId: String, + /** The version actually installed on the server when it could be discovered. */ + val appVersion: String, + /** The signed release/tag from which this contract was acquired. */ + val contractVersion: String, + val specFile: String, + val document: String, + val catalogUrl: String, + val packageUrl: String, + val sourceUrl: String, + val sourceKind: OpenApiContractSourceKind, + val contractKind: VerifiedContractKind = VerifiedContractKind.OpenApi, +) + +enum class VerifiedContractKind { + OpenApi, + VerifiedReadRoutes, + OpenApiWithVerifiedReadRoutes, +} + +enum class OpenApiContractSourceKind { + SignedAppPackage, + SignedCompatibleAppPackage, + AppStoreLinkedExactGitHubTag, + AppStoreLinkedCompatibleGitHubTag, +} + +data class GitHubSourceReference( + val owner: String, + val repository: String, + val tag: String, +) + +data class AppStoreRelease( + val appId: String, + val version: String, + val downloadUrl: String, + val signature: String, + val signatureDigest: String, + val certificatePem: String, + val catalogUrl: String, + val platformVersionSpec: String? = null, + val githubSources: List = emptyList(), +) + +interface AppPackageTrustVerifier { + fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract +} + +data class VerifiedPackageContract( + val appId: String, + val appVersion: String, + val specFile: String, + val document: String, + val contractKind: VerifiedContractKind = VerifiedContractKind.OpenApi, +) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 9d165feec..2adbbda8d 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -26,70 +26,6 @@ import org.xml.sax.EntityResolver import org.xml.sax.SAXException import javax.xml.parsers.DocumentBuilderFactory -data class ContractAcquisitionRequest( - val appId: String, - val serverVersion: String, - val installedAppVersion: String? = null, -) - -data class VerifiedOpenApiContract( - val appId: String, - /** The version actually installed on the server when it could be discovered. */ - val appVersion: String, - /** The signed release/tag from which this contract was acquired. */ - val contractVersion: String, - val specFile: String, - val document: String, - val catalogUrl: String, - val packageUrl: String, - val sourceUrl: String, - val sourceKind: OpenApiContractSourceKind, - val contractKind: VerifiedContractKind = VerifiedContractKind.OpenApi, -) - -enum class VerifiedContractKind { - OpenApi, - VerifiedReadRoutes, - OpenApiWithVerifiedReadRoutes, -} - -enum class OpenApiContractSourceKind { - SignedAppPackage, - SignedCompatibleAppPackage, - AppStoreLinkedExactGitHubTag, - AppStoreLinkedCompatibleGitHubTag, -} - -data class GitHubSourceReference( - val owner: String, - val repository: String, - val tag: String, -) - -data class AppStoreRelease( - val appId: String, - val version: String, - val downloadUrl: String, - val signature: String, - val signatureDigest: String, - val certificatePem: String, - val catalogUrl: String, - val platformVersionSpec: String? = null, - val githubSources: List = emptyList(), -) - -interface AppPackageTrustVerifier { - fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract -} - -data class VerifiedPackageContract( - val appId: String, - val appVersion: String, - val specFile: String, - val document: String, - val contractKind: VerifiedContractKind = VerifiedContractKind.OpenApi, -) - class SignedAppStoreContractAcquirer( private val httpClient: OkHttpClient = defaultContractHttpClient(), private val appStoreBaseUrl: String = OFFICIAL_APP_STORE_BASE_URL, diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 0a8fc2fa6..b6e54c463 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1212 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1148 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 From b36b19f9173589ec382ba856605d68b6e2c0c5ad Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 15 Oct 2026 03:12:46 +0200 Subject: [PATCH 06/49] refactor(contracts): move package trust verification into its own file --- .../contracts/ContractAcquisitionOutcome.kt | 41 +++++++++ .../NextcloudAppPackageTrustVerifier.kt | 84 +++++++++++++++++++ .../SignedAppStoreContractAcquirer.kt | 81 +----------------- tools/kotlin-file-size-baseline.txt | 2 +- 4 files changed, 127 insertions(+), 81 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt new file mode 100644 index 000000000..514b6dc48 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -0,0 +1,41 @@ +package dev.obiente.nextcloudnative.contracts + +/** Why an acquisition completed normally without a verified contract. */ +enum class ContractUnavailableReason { + /** The App Store lists no release of this app for the installed or a patch-compatible version. */ + NoMatchingRelease, + + /** + * The App Store-linked source host refused or rate limited the last fallback request (HTTP 403 + * or 429). A contract may exist, so the result is not evidence that the release has none. + */ + SourceTemporarilyUnavailable, + + /** Matching releases were inspected and none provides a usable contract or safe read routes. */ + NoUsableContract, +} + +/** The typed result of [SignedAppStoreContractAcquirer.acquireOutcome]. Failures are thrown instead. */ +sealed interface ContractAcquisitionOutcome { + data class Acquired(val contract: VerifiedOpenApiContract) : ContractAcquisitionOutcome + + data class Unavailable(val reason: ContractUnavailableReason) : ContractAcquisitionOutcome +} + +/** Evidence gathered by one uncoalesced acquisition that decides its no-contract reason. */ +internal class ContractAcquisitionAttempt { + var sourceTemporarilyUnavailable: Boolean = false + private set + + fun markSourceTemporarilyUnavailable() { + sourceTemporarilyUnavailable = true + } + + fun unavailable(): ContractAcquisitionOutcome.Unavailable = ContractAcquisitionOutcome.Unavailable( + if (sourceTemporarilyUnavailable) { + ContractUnavailableReason.SourceTemporarilyUnavailable + } else { + ContractUnavailableReason.NoUsableContract + }, + ) +} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt new file mode 100644 index 000000000..f508ff538 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt @@ -0,0 +1,84 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509CRL +import java.security.cert.X509Certificate +import java.util.Base64 + +class NextcloudAppPackageTrustVerifier( + private val trustResourceLoader: (String) -> ByteArray = ::loadBundledTrustResource, +) : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract { + require(release.signatureDigest.equals("sha512", ignoreCase = true)) { + "The package uses an unsupported signature digest." + } + val factory = CertificateFactory.getInstance("X.509") + val trustedCertificates = factory.generateCertificates( + ByteArrayInputStream(trustResourceLoader(ROOT_CERTIFICATES_RESOURCE)), + ).map { certificate -> certificate as X509Certificate } + check(trustedCertificates.isNotEmpty()) { "The Nextcloud trust bundle is empty." } + trustedCertificates.forEach(X509Certificate::checkValidity) + val appCertificate = factory.generateCertificate( + ByteArrayInputStream(release.certificatePem.encodeToByteArray()), + ) as X509Certificate + appCertificate.checkValidity() + check(appCertificate.subjectCommonName() == release.appId) { + "The package certificate does not belong to ${release.appId}." + } + check(appCertificate.sigAlgName.contains("RSA", ignoreCase = true)) { + "The package certificate uses an unsupported key algorithm." + } + verifyCertificateChain(appCertificate, trustedCertificates) + val crl = factory.generateCRL( + ByteArrayInputStream(trustResourceLoader(ROOT_REVOCATION_LIST_RESOURCE)), + ) as X509CRL + val crlIssuer = trustedCertificates.firstOrNull { certificate -> + certificate.subjectX500Principal == crl.issuerX500Principal + } ?: error("The CRL issuer is missing from the Nextcloud trust bundle.") + crl.verify(crlIssuer.publicKey) + check(!crl.isRevoked(appCertificate)) { "The package certificate has been revoked." } + val signature = Signature.getInstance("SHA512withRSA") + signature.initVerify(appCertificate.publicKey) + signature.update(archive) + val signatureBytes = Base64.getMimeDecoder().decode(release.signature) + check(signature.verify(signatureBytes)) { "The downloaded app package signature is invalid." } + return extractOpenApiContract(archive, release) + } +} + +private fun verifyCertificateChain( + leaf: X509Certificate, + trustedCertificates: List, +) { + var current = leaf + val visited = mutableSetOf() + repeat(trustedCertificates.size + 1) { + val identity = current.subjectX500Principal.name + check(visited.add(identity)) { "The package certificate chain contains a cycle." } + val issuer = trustedCertificates.firstOrNull { candidate -> + candidate.subjectX500Principal == current.issuerX500Principal + } ?: error("The package certificate was not issued by Nextcloud's trusted authority.") + current.verify(issuer.publicKey) + if (issuer.subjectX500Principal == issuer.issuerX500Principal) { + issuer.verify(issuer.publicKey) + return + } + current = issuer + } + error("The package certificate chain is too deep.") +} + +private fun X509Certificate.subjectCommonName(): String? { + val principal = subjectX500Principal.getName("RFC2253") + return Regex("(?:^|,)CN=([^,]+)").find(principal)?.groupValues?.get(1) +} + +private fun loadBundledTrustResource(name: String): ByteArray = + NextcloudAppPackageTrustVerifier::class.java.classLoader.getResourceAsStream(name)?.use(InputStream::readBytes) + ?: error("The bundled Nextcloud trust resource is missing: $name") + +private const val ROOT_CERTIFICATES_RESOURCE = "nextcloud-codesigning/root.crt" +private const val ROOT_REVOCATION_LIST_RESOURCE = "nextcloud-codesigning/root.crl" diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 2adbbda8d..fc2e2199a 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -3,12 +3,7 @@ package dev.obiente.nextcloudnative.contracts import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream -import java.security.Signature -import java.security.cert.CertificateFactory -import java.security.cert.X509CRL -import java.security.cert.X509Certificate import java.net.URI -import java.util.Base64 import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException @@ -739,77 +734,9 @@ internal fun compareSemanticVersions(left: String, right: String): Int { return leftVersion.suffix.compareTo(rightVersion.suffix) } -class NextcloudAppPackageTrustVerifier( - private val trustResourceLoader: (String) -> ByteArray = ::loadBundledTrustResource, -) : AppPackageTrustVerifier { - override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract { - require(release.signatureDigest.equals("sha512", ignoreCase = true)) { - "The package uses an unsupported signature digest." - } - val factory = CertificateFactory.getInstance("X.509") - val trustedCertificates = factory.generateCertificates( - ByteArrayInputStream(trustResourceLoader(ROOT_CERTIFICATES_RESOURCE)), - ).map { certificate -> certificate as X509Certificate } - check(trustedCertificates.isNotEmpty()) { "The Nextcloud trust bundle is empty." } - trustedCertificates.forEach(X509Certificate::checkValidity) - val appCertificate = factory.generateCertificate( - ByteArrayInputStream(release.certificatePem.encodeToByteArray()), - ) as X509Certificate - appCertificate.checkValidity() - check(appCertificate.subjectCommonName() == release.appId) { - "The package certificate does not belong to ${release.appId}." - } - check(appCertificate.sigAlgName.contains("RSA", ignoreCase = true)) { - "The package certificate uses an unsupported key algorithm." - } - verifyCertificateChain(appCertificate, trustedCertificates) - val crl = factory.generateCRL( - ByteArrayInputStream(trustResourceLoader(ROOT_REVOCATION_LIST_RESOURCE)), - ) as X509CRL - val crlIssuer = trustedCertificates.firstOrNull { certificate -> - certificate.subjectX500Principal == crl.issuerX500Principal - } ?: error("The CRL issuer is missing from the Nextcloud trust bundle.") - crl.verify(crlIssuer.publicKey) - check(!crl.isRevoked(appCertificate)) { "The package certificate has been revoked." } - val signature = Signature.getInstance("SHA512withRSA") - signature.initVerify(appCertificate.publicKey) - signature.update(archive) - val signatureBytes = Base64.getMimeDecoder().decode(release.signature) - check(signature.verify(signatureBytes)) { "The downloaded app package signature is invalid." } - return extractOpenApiContract(archive, release) - } -} - class OpenApiContractMissingException(message: String) : IllegalStateException(message) -private fun verifyCertificateChain( - leaf: X509Certificate, - trustedCertificates: List, -) { - var current = leaf - val visited = mutableSetOf() - repeat(trustedCertificates.size + 1) { - val identity = current.subjectX500Principal.name - check(visited.add(identity)) { "The package certificate chain contains a cycle." } - val issuer = trustedCertificates.firstOrNull { candidate -> - candidate.subjectX500Principal == current.issuerX500Principal - } ?: error("The package certificate was not issued by Nextcloud's trusted authority.") - current.verify(issuer.publicKey) - if (issuer.subjectX500Principal == issuer.issuerX500Principal) { - issuer.verify(issuer.publicKey) - return - } - current = issuer - } - error("The package certificate chain is too deep.") -} - -private fun X509Certificate.subjectCommonName(): String? { - val principal = subjectX500Principal.getName("RFC2253") - return Regex("(?:^|,)CN=([^,]+)").find(principal)?.groupValues?.get(1) -} - -private fun extractOpenApiContract( +internal fun extractOpenApiContract( archive: ByteArray, release: AppStoreRelease, ): VerifiedPackageContract { @@ -1121,12 +1048,6 @@ private fun URI.effectivePort(): Int = when { else -> -1 } -private fun loadBundledTrustResource(name: String): ByteArray = - NextcloudAppPackageTrustVerifier::class.java.classLoader.getResourceAsStream(name)?.use(InputStream::readBytes) - ?: error("The bundled Nextcloud trust resource is missing: $name") - -private const val ROOT_CERTIFICATES_RESOURCE = "nextcloud-codesigning/root.crt" -private const val ROOT_REVOCATION_LIST_RESOURCE = "nextcloud-codesigning/root.crl" private const val TAR_BLOCK_BYTES = 512 private const val MAX_TAR_ENTRY_BYTES = 128L * 1024L * 1024L private const val MAX_TAR_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index b6e54c463..234411098 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1148 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1069 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 From f7f6df1bd7db6f2a31efd82945556724f2411fc0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 15 Oct 2026 03:15:46 +0200 Subject: [PATCH 07/49] refactor(contracts): move OpenAPI YAML parsing into its own file --- .../contracts/OpenApiYamlParser.kt | 43 +++++++++++++++++++ .../SignedAppStoreContractAcquirer.kt | 40 +---------------- tools/kotlin-file-size-baseline.txt | 2 +- 3 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/OpenApiYamlParser.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/OpenApiYamlParser.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/OpenApiYamlParser.kt new file mode 100644 index 000000000..01eac4c08 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/OpenApiYamlParser.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative.contracts + +import org.json.JSONArray +import org.json.JSONObject +import org.snakeyaml.engine.v2.api.Load +import org.snakeyaml.engine.v2.api.LoadSettings + +internal fun parseYamlObject(source: String): JSONObject? { + val settings = LoadSettings.builder() + .setLabel("OpenAPI contract") + .setAllowDuplicateKeys(false) + .setMaxAliasesForCollections(MAX_YAML_ALIASES) + .setCodePointLimit(MAX_SELECTED_FILE_BYTES.toInt()) + .build() + val loaded = Load(settings).loadFromString(source) ?: return null + return yamlToJson(loaded, 0, intArrayOf(0)) as? JSONObject +} + +private fun yamlToJson(value: Any?, depth: Int, nodes: IntArray): Any { + check(depth <= MAX_YAML_DEPTH) { "The OpenAPI YAML is nested too deeply." } + nodes[0] += 1 + check(nodes[0] <= MAX_YAML_NODES) { "The OpenAPI YAML has too many values." } + return when (value) { + null -> JSONObject.NULL + is Map<*, *> -> JSONObject().also { result -> + value.forEach { (key, child) -> + check(key is String || key is Number || key is Boolean) { + "OpenAPI YAML object keys must be scalar values." + } + result.put(key.toString(), yamlToJson(child, depth + 1, nodes)) + } + } + is Iterable<*> -> JSONArray().also { result -> + value.forEach { child -> result.put(yamlToJson(child, depth + 1, nodes)) } + } + is String, is Number, is Boolean -> value + else -> value.toString() + } +} + +private const val MAX_YAML_ALIASES = 32 +private const val MAX_YAML_DEPTH = 100 +private const val MAX_YAML_NODES = 250_000 diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index fc2e2199a..7a1f67a15 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -14,8 +14,6 @@ import okhttp3.Request import okhttp3.Response import org.json.JSONArray import org.json.JSONObject -import org.snakeyaml.engine.v2.api.Load -import org.snakeyaml.engine.v2.api.LoadSettings import org.w3c.dom.Element import org.xml.sax.EntityResolver import org.xml.sax.SAXException @@ -842,39 +840,6 @@ private fun resolveJsonPointer(root: JSONObject, pointer: String): Any? { return value } -internal fun parseYamlObject(source: String): JSONObject? { - val settings = LoadSettings.builder() - .setLabel("OpenAPI contract") - .setAllowDuplicateKeys(false) - .setMaxAliasesForCollections(MAX_YAML_ALIASES) - .setCodePointLimit(MAX_SELECTED_FILE_BYTES.toInt()) - .build() - val loaded = Load(settings).loadFromString(source) ?: return null - return yamlToJson(loaded, 0, intArrayOf(0)) as? JSONObject -} - -private fun yamlToJson(value: Any?, depth: Int, nodes: IntArray): Any { - check(depth <= MAX_YAML_DEPTH) { "The OpenAPI YAML is nested too deeply." } - nodes[0] += 1 - check(nodes[0] <= MAX_YAML_NODES) { "The OpenAPI YAML has too many values." } - return when (value) { - null -> JSONObject.NULL - is Map<*, *> -> JSONObject().also { result -> - value.forEach { (key, child) -> - check(key is String || key is Number || key is Boolean) { - "OpenAPI YAML object keys must be scalar values." - } - result.put(key.toString(), yamlToJson(child, depth + 1, nodes)) - } - } - is Iterable<*> -> JSONArray().also { result -> - value.forEach { child -> result.put(yamlToJson(child, depth + 1, nodes)) } - } - is String, is Number, is Boolean -> value - else -> value.toString() - } -} - internal fun parseAppInfoIdentity(infoXml: String): Pair { check(!infoXml.contains(" Date: Thu, 15 Oct 2026 03:38:31 +0200 Subject: [PATCH 08/49] fix: carry a typed reason when App Store acquisition finds no contract A completed acquisition without a contract no longer collapses to null. The contract module reports whether no release matches the installed version, the linked source refused or rate limited the last exact-tag fallback, or the inspected releases provide no usable contract. The shared JVM boundary carries that reason to discovery, and the fallback card derives its copy and action from it. --- DYNAMIC_APP_DESCRIPTOR.md | 24 ++- .../AndroidNextcloudServices.kt | 6 +- .../SignedAppStoreContractAcquirer.kt | 40 +++-- .../ContractAcquisitionOutcomeTest.kt | 151 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicContractFallback.kt | 52 +++++- .../app/DynamicDiscoveryDiagnostics.kt | 3 +- .../app/DynamicNativeRuntime.kt | 14 +- .../nextcloudnative/app/NextcloudPlatform.kt | 2 +- .../app/DesktopNextcloudServices.kt | 4 +- .../DynamicContractFallbackDiscoveryTest.kt | 36 ++++- .../app/JvmAppStoreContractAcquisitionTest.kt | 20 ++- .../app/JvmAppStoreContractAcquisition.kt | 20 ++- 13 files changed, 325 insertions(+), 49 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcomeTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index bf1a9b786..2a654c64c 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -184,7 +184,10 @@ keeps the metadata-only descriptor and records a typed reason in [`DynamicContractFallback.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt). The fallback card derives its title, explanation, and action from that reason: -- no verified contract exists for the installed version; +- the inspected releases provide no verified contract for the installed version; +- the App Store lists no release matching the installed version; +- the app's linked source refused or rate limited the last fallback request + (HTTP 403 or 429), so a contract may still exist; - the server or app version could not be confirmed; - the App Store could not be reached, or did not provide the release; - the package or its source failed verification; @@ -203,6 +206,13 @@ Android ICU engine rejects a regular expression, the result is a client-fault fallback, not an exception class name. Diagnostics and product copy never contain exception text, and a fallback reason is never persisted. +A completed acquisition without a contract is a typed outcome, not a bare +`null`. +[`ContractAcquisitionOutcome.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt) +separates no matching release, a temporarily unavailable linked source, and +releases inspected without a usable contract, and the shared JVM boundary +carries that reason to the fallback card. + ## Read failures [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) @@ -242,9 +252,15 @@ bodies, invalid text, and deeply nested JSON. - Host placeholders are parsed without a brace regular expression. The `ui` build checks `commonMain`. `tools/check-kotlin-architecture.sh` checks the shared `ui` source sets compiled for Android, the contract module, and every - `androidApp` source set except JVM unit tests. An Android instrumented test - initializes every top-level contract-module class and exercises host - rejection on the Android runtime. + `androidApp` source set except JVM unit tests. +- `AppOwnedOpenApiAndroidInstrumentedTest` initializes every top-level + contract-module class, exercises host rejection, the real catalog path with + a failing cache, and YAML parsing on the Android runtime. CI does not run + instrumented tests; run it with the emulator workflow in + [CONTRIBUTING.md](CONTRIBUTING.md). The JVM unit test + `ContractModuleInitializationTest` loads every facade and fails when one is + missing from the instrumented list. JVM loading does not prove ART or ICU + compatibility. - Path and operation server overrides must pass the same origin checks and resolve to the root server path base. Different override bases are rejected, not ignored. An absent override inherits the base; an explicit empty array diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 3e7cf9b41..d38fd23c2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -13,7 +13,7 @@ import android.provider.DocumentsContract import android.provider.Settings import android.util.Base64 import android.util.Log -import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract +import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition import dev.obiente.nextcloudnative.app.acquireAppStoreContract import dev.obiente.nextcloudnative.app.DeckAttachment import dev.obiente.nextcloudnative.app.DeckAttachmentOpenTarget @@ -2960,10 +2960,10 @@ internal class AndroidNextcloudServices( appId: String, serverVersion: String, installedAppVersion: String?, - ): AcquiredOpenApiContract? = withContext(Dispatchers.IO) { + ): AppStoreContractAcquisition = withContext(Dispatchers.IO) { acquireAppStoreContract( ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), - contractAcquirer::acquire, + contractAcquirer::acquireOutcome, ) } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 7a1f67a15..4d24f7a03 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -32,7 +32,7 @@ class SignedAppStoreContractAcquirer( ) { private val verifiedContracts = ConcurrentHashMap() private val inFlightAcquisitions = - ConcurrentHashMap>() + ConcurrentHashMap>() init { if (requireHttps) { @@ -48,8 +48,12 @@ class SignedAppStoreContractAcquirer( } } - fun acquire(request: ContractAcquisitionRequest): VerifiedOpenApiContract? { - val owner = CompletableFuture() + /** Returns the verified contract, or null for any [ContractAcquisitionOutcome.Unavailable] reason. */ + fun acquire(request: ContractAcquisitionRequest): VerifiedOpenApiContract? = + (acquireOutcome(request) as? ContractAcquisitionOutcome.Acquired)?.contract + + fun acquireOutcome(request: ContractAcquisitionRequest): ContractAcquisitionOutcome { + val owner = CompletableFuture() val existing = inFlightAcquisitions.putIfAbsent(request, owner) if (existing != null) { return try { @@ -71,16 +75,16 @@ class SignedAppStoreContractAcquirer( } } - private fun acquireUncoalesced(request: ContractAcquisitionRequest): VerifiedOpenApiContract? { + private fun acquireUncoalesced(request: ContractAcquisitionRequest): ContractAcquisitionOutcome { requireValidRequest(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } val coreServerVersion = normalizeServerVersion(request.serverVersion) request.installedAppVersion?.let { version -> requireValidRequest(version.matches(APP_VERSION_PATTERN)) { "The installed app version is invalid." } } - verifiedContracts[request]?.let { return it } + verifiedContracts[request]?.let { return ContractAcquisitionOutcome.Acquired(it) } verifiedContractCache.load(request)?.let { cached -> verifiedContracts[request] = cached - return cached + return ContractAcquisitionOutcome.Acquired(cached) } val compatibleCatalogUrl = "${appStoreBaseUrl.trimEnd('/')}/platform/$coreServerVersion/apps.json" @@ -100,7 +104,8 @@ class SignedAppStoreContractAcquirer( installedVersion = request.installedAppVersion, ).filterNot { candidate -> candidate.version == exactRelease?.version } val releases = listOfNotNull(exactRelease) + compatibleReleases - if (releases.isEmpty()) return null + if (releases.isEmpty()) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoMatchingRelease) + val attempt = ContractAcquisitionAttempt() val missingPackageContracts = mutableListOf() val verifiedRouteFallbacks = mutableListOf>() @@ -128,6 +133,7 @@ class SignedAppStoreContractAcquirer( release = release, installedAppVersion = request.installedAppVersion, exact = release.version == request.installedAppVersion, + attempt = attempt, )?.let { linkedContract -> return cacheContract( request, @@ -142,11 +148,11 @@ class SignedAppStoreContractAcquirer( for (release in missingPackageContracts) { val exact = release.version == request.installedAppVersion - acquireGitHubTagContract(release, request.installedAppVersion, exact)?.let { contract -> + acquireGitHubTagContract(release, request.installedAppVersion, exact, attempt)?.let { contract -> return cacheContract(request, contract) } } - val (release, verified) = verifiedRouteFallbacks.firstOrNull() ?: return null + val (release, verified) = verifiedRouteFallbacks.firstOrNull() ?: return attempt.unavailable() return cacheContract(request, verified.asPackageContract(request, release)) } @@ -173,15 +179,16 @@ class SignedAppStoreContractAcquirer( private fun cacheContract( request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract, - ): VerifiedOpenApiContract = contract.also { - verifiedContracts[request] = it - runCatching { verifiedContractCache.store(request, it) } + ): ContractAcquisitionOutcome = ContractAcquisitionOutcome.Acquired(contract).also { + verifiedContracts[request] = contract + runCatching { verifiedContractCache.store(request, contract) } } private fun acquireGitHubTagContract( release: AppStoreRelease, installedAppVersion: String?, exact: Boolean, + attempt: ContractAcquisitionAttempt, ): VerifiedOpenApiContract? { release.githubSources.forEach sourceLoop@ { source -> val sourceRoot = "${rawGitHubBaseUrl.trimEnd('/')}/${source.owner}/${source.repository}/${source.tag}" @@ -227,7 +234,7 @@ class SignedAppStoreContractAcquirer( parseOpenApiCandidate(fileName, contractBytes)?.copy(sourceUrl = specUrl) } } - } ?: listOfNotNull(acquireCodeloadCandidate(source, release, sourceRoot)) + } ?: listOfNotNull(acquireCodeloadCandidate(source, release, sourceRoot, attempt)) selectOpenApiCandidate(contracts, release.appId) ?.let { selected -> val bundled = bundleGitHubSchemaReferences(sourceRoot, selected) @@ -256,6 +263,7 @@ class SignedAppStoreContractAcquirer( source: GitHubSourceReference, release: AppStoreRelease, sourceRoot: String, + attempt: ContractAcquisitionAttempt, ): OpenApiCandidate? { val archiveUrl = "${codeloadGitHubBaseUrl.trimEnd('/')}/${source.owner}/${source.repository}" + "/tar.gz/refs/tags/${source.tag}" @@ -264,6 +272,8 @@ class SignedAppStoreContractAcquirer( expectedBaseUrl = codeloadGitHubBaseUrl, maximumBytes = MAX_ARCHIVE_BYTES, recoverableStatusCodes = setOf(403, 429), + // The archive is the last exact-tag fallback; a refusal here leaves the outcome unknown. + onRecoverableStatus = attempt::markSourceTemporarilyUnavailable, ) ?: return null val extracted = try { extractOpenApiContract(archive, release) @@ -395,11 +405,13 @@ class SignedAppStoreContractAcquirer( maximumBytes: Long, accept: String = "application/octet-stream", recoverableStatusCodes: Set = emptySet(), + onRecoverableStatus: () -> Unit = {}, ): ByteArray? { requireSecureUrl(url) val response = execute(Request.Builder().url(url).header("Accept", accept).get().build()) return response.use { githubResponse -> - if (githubResponse.code == 404 || githubResponse.code in recoverableStatusCodes) return@use null + if (githubResponse.code == 404) return@use null + if (githubResponse.code in recoverableStatusCodes) return@use null.also { onRecoverableStatus() } requireSuccessfulSource(githubResponse) { "Loading an exact-tag GitHub contract resource failed (HTTP ${githubResponse.code})." } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcomeTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcomeTest.kt new file mode 100644 index 000000000..61718e1d1 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcomeTest.kt @@ -0,0 +1,151 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONObject + +class ContractAcquisitionOutcomeTest { + @Test + fun `an app absent from the catalog has no matching release`() { + MockWebServer().use { server -> + server.start() + repeat(2) { server.enqueue(MockResponse(body = """[{"id":"other","certificate":"c","releases":[]}]""")) } + val acquirer = acquirer(server) + assertUnavailable(ContractUnavailableReason.NoMatchingRelease, acquirer, "4.0.2") + // The nullable API stays a projection of the typed outcome; the catalog is reused. + assertNull(acquirer.acquire(request("4.0.2"))) + assertEquals(2, server.requestCount) + } + } + + @Test + fun `an installed version absent from every catalog has no matching release`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) + server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) + assertUnavailable(ContractUnavailableReason.NoMatchingRelease, acquirer(server), "4.0.2") + assertEquals(2, server.requestCount) + } + } + + @Test + fun `a throttled or refused exact-tag archive leaves the contract temporarily unavailable`() { + for (status in listOf(403, 429)) { + MockWebServer().use { server -> + server.start() + enqueueMissingPackage(server) + server.enqueue(MockResponse(code = 403, body = "rate limited")) + server.enqueue(MockResponse(code = status, body = "rate limited")) + val acquirer = acquirer(server) + assertUnavailable(ContractUnavailableReason.SourceTemporarilyUnavailable, acquirer, "4.0.2") + assertEquals("/codeload/source-owner/source-repo/tar.gz/refs/tags/v4.0.2", lastPath(server)) + } + } + } + + @Test + fun `an inspected release without a contract is not reported as throttled`() { + // A throttled tree listing that falls back to a missing archive, and a complete tree + // without OpenAPI files, both prove the tag has no usable contract. + for (responses in listOf( + listOf(MockResponse(code = 403, body = "rate limited"), MockResponse(code = 404, body = "missing")), + listOf(MockResponse(body = tree("README.md"))), + )) { + MockWebServer().use { server -> + server.start() + enqueueMissingPackage(server) + responses.forEach(server::enqueue) + assertUnavailable(ContractUnavailableReason.NoUsableContract, acquirer(server), "4.0.2") + } + } + } + + @Test + fun `a package without a contract and without linked sources has no usable contract`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "4.0.2", website = null, linkedDownload = false))) + server.enqueue(MockResponse(body = "signed-package-without-openapi")) + assertUnavailable(ContractUnavailableReason.NoUsableContract, acquirer(server), "4.0.2") + assertEquals(2, server.requestCount) + } + } + + @Test + fun `an acquired contract is the acquired outcome`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) + server.enqueue(MockResponse(body = "package")) + val outcome = acquirer(server) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }.acquireOutcome(request("4.0.2")) + val acquired = assertIs(outcome) + assertEquals(OpenApiContractSourceKind.SignedAppPackage, acquired.contract.sourceKind) + } + } + + private fun assertUnavailable( + expected: ContractUnavailableReason, + acquirer: SignedAppStoreContractAcquirer, + installedVersion: String, + ) { + assertEquals(ContractAcquisitionOutcome.Unavailable(expected), acquirer.acquireOutcome(request(installedVersion))) + } + + private fun enqueueMissingPackage(server: MockWebServer) { + server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) + server.enqueue(MockResponse(body = "signed-package-without-openapi")) + server.enqueue(MockResponse(body = "cospend4.0.2")) + } + + private fun lastPath(server: MockWebServer): String? = + List(server.requestCount) { server.takeRequest().url.encodedPath }.lastOrNull() + + private fun request(installedVersion: String) = ContractAcquisitionRequest("cospend", "34.0.3", installedVersion) + + private fun catalog( + server: MockWebServer, + version: String, + website: String? = "https://github.com/source-owner/source-repo", + linkedDownload: Boolean = true, + ): String { + val download = if (linkedDownload) { + server.url("source-owner/source-repo/releases/download/v$version/cospend-$version.tar.gz") + } else { + server.url("downloads/cospend-$version.tar.gz") + } + val websiteField = website?.let { "\"website\":\"$it\"," }.orEmpty() + return """[{"id":"cospend",$websiteField"certificate":"certificate","releases":[""" + + """{"version":"$version","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""" + } + + private fun tree(vararg paths: String): String = JSONObject() + .put("truncated", false) + .put("tree", paths.map { path -> JSONObject().put("path", path).put("type", "blob") }) + .toString() + + private fun acquirer( + server: MockWebServer, + verify: (AppStoreRelease) -> VerifiedPackageContract = { + throw OpenApiContractMissingException("synthetic package without a contract") + }, + ) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + rawGitHubBaseUrl = server.url("raw").toString(), + githubApiBaseUrl = server.url("github-api").toString(), + codeloadGitHubBaseUrl = server.url("codeload").toString(), + ) +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 9a71e4c3d..cab3e7783 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1031 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1043 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index fe81641c2..1dec56aaf 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -23,6 +23,25 @@ enum class AppStoreContractFailureKind { Unexpected, } +/** Why the App Store boundary completed normally without a verified contract. */ +enum class AppStoreContractUnavailableReason { + /** The App Store lists no release for the installed or a patch-compatible version. */ + NoMatchingRelease, + + /** The linked source refused or rate limited the last fallback request, so a contract may exist. */ + SourceTemporarilyUnavailable, + + /** Matching releases were inspected and none provides a usable contract or safe read routes. */ + NoUsableContract, +} + +/** Result of [NextcloudPlatformServices.acquireSignedOpenApiContract]. Failures are thrown instead. */ +sealed interface AppStoreContractAcquisition { + data class Acquired(val contract: AcquiredOpenApiContract) : AppStoreContractAcquisition + + data class Unavailable(val reason: AppStoreContractUnavailableReason) : AppStoreContractAcquisition +} + /** * The only failure [NextcloudPlatformServices.acquireSignedOpenApiContract] reports. * @@ -37,8 +56,10 @@ class AppStoreContractAcquisitionException( /** Why discovery fell back to app metadata. Verified and persisted contracts carry no reason. */ @Serializable enum class DynamicContractFallbackReason { - /** No same-origin or App Store source provides a verified contract for this version. */ + /** Matching releases were inspected and none provides a verified contract for this version. */ NoVerifiedContract, + NoMatchingRelease, + SourceTemporarilyUnavailable, VersionUnavailable, AppStoreUnreachable, AppStoreUnavailable, @@ -57,6 +78,13 @@ internal fun AppStoreContractFailureKind.fallbackReason(): DynamicContractFallba -> DynamicContractFallbackReason.ClientFault } +internal fun AppStoreContractUnavailableReason.fallbackReason(): DynamicContractFallbackReason = when (this) { + AppStoreContractUnavailableReason.NoMatchingRelease -> DynamicContractFallbackReason.NoMatchingRelease + AppStoreContractUnavailableReason.SourceTemporarilyUnavailable -> + DynamicContractFallbackReason.SourceTemporarilyUnavailable + AppStoreContractUnavailableReason.NoUsableContract -> DynamicContractFallbackReason.NoVerifiedContract +} + /** Classifies a failure from the platform acquisition boundary without reading exception text. */ internal fun Throwable.appStoreContractFailureKind(): AppStoreContractFailureKind = (this as? AppStoreContractAcquisitionException)?.kind ?: AppStoreContractFailureKind.Unexpected @@ -67,6 +95,16 @@ internal fun DynamicDescriptorDiscovery.withFallback( diagnostic: String, ): DynamicDescriptorDiscovery = copy(diagnostics = diagnostics + diagnostic, fallbackReason = reason) +/** Keeps the metadata fallback when the App Store boundary completed without a contract. */ +internal fun DynamicDescriptorDiscovery.withUnavailable( + reason: AppStoreContractUnavailableReason, + appId: String, + coreVersion: String, +): DynamicDescriptorDiscovery = withFallback( + reason.fallbackReason(), + "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", +) + /** Shown when discovery itself fails before producing even a metadata fallback. */ internal const val DYNAMIC_DISCOVERY_FAILURE_MESSAGE = "Could not check this app's native API. Try again." @@ -89,6 +127,18 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "contract or safe read routes. Only app details are shown.", "Check again", ) + DynamicContractFallbackReason.NoMatchingRelease -> DynamicContractFallbackNotice( + "No matching App Store release", + "The Nextcloud App Store lists no signed release for this app version, so its API cannot be " + + "verified. Only app details are shown.", + "Check again", + ) + DynamicContractFallbackReason.SourceTemporarilyUnavailable -> DynamicContractFallbackNotice( + "App source busy", + "The app's linked source refused or limited requests, so its API contract could not be checked " + + "yet. Try again later.", + "Try again", + ) DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( "App version not confirmed", "The server or app version could not be confirmed, so no matching signed App Store release " + diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt index 4b46dd335..02f78cc56 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt @@ -10,12 +10,13 @@ internal enum class DynamicDiscoveryStage(val diagnosticValue: String) { internal suspend fun observeDynamicDiscoveryStage( stage: DynamicDiscoveryStage, record: (SupportDiagnosticEventDraft) -> Unit, + available: (T) -> Boolean = { it != null }, block: suspend () -> T, ): Result { val result = runCatchingPreservingCancellation(block) val outcome = when { result.isFailure -> "failed" - result.getOrNull() == null -> "unavailable" + !available(result.getOrThrow()) -> "unavailable" else -> "completed" } // Diagnostics are supplementary: a recorder failure must not discard a usable contract. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 73f68a9a9..faae2db86 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -156,18 +156,18 @@ suspend fun discoverDynamicAppDescriptor( "Finding a verified app package", ), ) - val acquired = observeDynamicDiscoveryStage(DynamicDiscoveryStage.PackageAcquisition, services::recordSupportDiagnostic) { + val acquisition = observeDynamicDiscoveryStage(DynamicDiscoveryStage.PackageAcquisition, services::recordSupportDiagnostic, + available = { it is AppStoreContractAcquisition.Acquired }) { services.acquireSignedOpenApiContract(contractAppId, coreVersion, installedVersion) }.getOrElse { failure -> // Only the typed kind enters diagnostics and copy; exception text and class names stay local. val kind = failure.appStoreContractFailureKind() return sameOrigin.withFallback(kind.fallbackReason(), "App Store contract acquisition failed: ${kind.name}.") - } ?: return sameOrigin.withFallback( - DynamicContractFallbackReason.NoVerifiedContract, - "No exact or patch-compatible App Store source yielded a usable API contract or " + - "verified static read routes for $contractAppId on Nextcloud $coreVersion. " + - "Only app metadata is available.", - ) + } + val acquired = when (acquisition) { + is AppStoreContractAcquisition.Acquired -> acquisition.contract + is AppStoreContractAcquisition.Unavailable -> return sameOrigin.withUnavailable(acquisition.reason, contractAppId, coreVersion) + } val versionStatus = acquired.effectiveDynamicContractVersionStatus(observedVersionStatus) val document = observeDynamicDiscoveryStage(DynamicDiscoveryStage.ContractParsing, services::recordSupportDiagnostic) { dynamicJson.parseToJsonElement(acquired.document) as? JsonObject diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 1d2175128..35f0a3814 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1443,7 +1443,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa appId: String, serverVersion: String, installedAppVersion: String?, - ): AcquiredOpenApiContract? + ): AppStoreContractAcquisition suspend fun listActivities(session: NextcloudSession, limit: Int = DEFAULT_ACTIVITY_LIMIT): List diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index bf35d0b5a..431678398 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -5035,10 +5035,10 @@ class DesktopNextcloudServices( appId: String, serverVersion: String, installedAppVersion: String?, - ): AcquiredOpenApiContract? = withContext(Dispatchers.IO) { + ): AppStoreContractAcquisition = withContext(Dispatchers.IO) { acquireAppStoreContract( ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), - contractAcquirer::acquire, + contractAcquirer::acquireOutcome, ) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index 1568347e4..9c0f36811 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -47,14 +47,34 @@ class DynamicContractFallbackDiscoveryTest { } @Test - fun missingContractUnavailableVersionAndInvalidContractEachHaveTheirOwnReason() = runBlocking { - assertEquals(DynamicContractFallbackReason.NoVerifiedContract, discover { null }.fallbackReason) + fun everyNoContractOutcomeHasItsOwnReasonAndCopy() = runBlocking { + val expected = mapOf( + AppStoreContractUnavailableReason.NoMatchingRelease to DynamicContractFallbackReason.NoMatchingRelease, + AppStoreContractUnavailableReason.SourceTemporarilyUnavailable to + DynamicContractFallbackReason.SourceTemporarilyUnavailable, + AppStoreContractUnavailableReason.NoUsableContract to DynamicContractFallbackReason.NoVerifiedContract, + ) + assertEquals(AppStoreContractUnavailableReason.entries.toSet(), expected.keys) + for ((unavailable, reason) in expected) { + val discovery = discover { AppStoreContractAcquisition.Unavailable(unavailable) } + assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition) + assertEquals(reason, discovery.fallbackReason) + assertTrue(unavailable.name in discovery.diagnostics.last()) + assertTrue("metadata-only" in discovery.toContractInfo(null).diagnosticCodes) + } + val throttled = DynamicContractFallbackReason.SourceTemporarilyUnavailable.fallbackNotice() + assertEquals("Try again", throttled.actionLabel) + assertEquals("Check again", DynamicContractFallbackReason.NoMatchingRelease.fallbackNotice().actionLabel) + } + + @Test + fun unavailableVersionAndInvalidContractEachHaveTheirOwnReason() = runBlocking { var acquisitions = 0 - val noVersion = discover(serverVersion = null) { acquisitions += 1; null } + val noVersion = discover(serverVersion = null) { acquisitions += 1; AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) } assertEquals(DynamicContractFallbackReason.VersionUnavailable, noVersion.fallbackReason) assertEquals(0, acquisitions) for (document in listOf("not json", "[]", """{"openapi":"3.0.3","paths":{"/apps/example/items":"""")) { - val invalid = discover { contract(document) } + val invalid = discover { AppStoreContractAcquisition.Acquired(contract(document)) } assertEquals(DynamicContractFallbackReason.UnsupportedContract, invalid.fallbackReason) assertNoPrivateText(invalid) } @@ -68,7 +88,7 @@ class DynamicContractFallbackDiscoveryTest { @Test fun fallbackReasonIsNeverPersistedAndLegacyEntriesDecodeWithoutIt() = runBlocking { - val fallback = discover { null } + val fallback = discover { AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) } assertNull(encodePersistedDynamicDiscovery(fallback)) val verified = fallback.copy(acquisition = DynamicDescriptorAcquisition.StaticAppAsset, fallbackReason = null) // Entries written before the field existed have the same shape as entries without a reason. @@ -101,7 +121,7 @@ class DynamicContractFallbackDiscoveryTest { private suspend fun discover( serverVersion: String? = "34.0.3", - acquire: suspend () -> AcquiredOpenApiContract?, + acquire: suspend () -> AppStoreContractAcquisition, ): DynamicDescriptorDiscovery = discoverDynamicAppDescriptor( services = services(acquire), session = session, @@ -122,7 +142,7 @@ class DynamicContractFallbackDiscoveryTest { ) /** Only same-origin probes and the App Store boundary are available; every probe is a miss. */ - private fun services(acquire: suspend () -> AcquiredOpenApiContract?): NextcloudPlatformServices { + private fun services(acquire: suspend () -> AppStoreContractAcquisition): NextcloudPlatformServices { val unavailable = Proxy.newProxyInstance( NextcloudPlatformServices::class.java.classLoader, arrayOf(NextcloudPlatformServices::class.java), @@ -138,7 +158,7 @@ class DynamicContractFallbackDiscoveryTest { appId: String, serverVersion: String, installedAppVersion: String?, - ): AcquiredOpenApiContract? = acquire() + ): AppStoreContractAcquisition = acquire() } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index e1186549c..9f8734a42 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequestException import dev.obiente.nextcloudnative.contracts.ContractSourceHttpException import dev.obiente.nextcloudnative.contracts.ContractSourceVerificationException @@ -13,7 +15,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertSame class JvmAppStoreContractAcquisitionTest { @@ -35,7 +36,10 @@ class JvmAppStoreContractAcquisitionTest { VerifiedContractKind.VerifiedReadRoutes to AcquiredContractKind.VerifiedReadRoutes, VerifiedContractKind.OpenApiWithVerifiedReadRoutes to AcquiredContractKind.OpenApiWithVerifiedReadRoutes, )) { - val acquired = requireNotNull(acquireAppStoreContract(request) { verified(source, kind) }) + val outcome = acquireAppStoreContract(request) { + ContractAcquisitionOutcome.Acquired(verified(source, kind)) + } + val acquired = (outcome as AppStoreContractAcquisition.Acquired).contract assertEquals(expectedSource, acquired.sourceKind) assertEquals(expectedKind, acquired.contractKind) assertEquals("1.0.1", acquired.appVersion) @@ -43,7 +47,17 @@ class JvmAppStoreContractAcquisitionTest { assertEquals("https://fixture.invalid/example.tar.gz#openapi.json", acquired.sourceUrl) } } - assertNull(acquireAppStoreContract(request) { null }) + for ((reason, expected) in listOf( + ContractUnavailableReason.NoMatchingRelease to AppStoreContractUnavailableReason.NoMatchingRelease, + ContractUnavailableReason.SourceTemporarilyUnavailable to + AppStoreContractUnavailableReason.SourceTemporarilyUnavailable, + ContractUnavailableReason.NoUsableContract to AppStoreContractUnavailableReason.NoUsableContract, + )) { + assertEquals( + AppStoreContractAcquisition.Unavailable(expected), + acquireAppStoreContract(request) { ContractAcquisitionOutcome.Unavailable(reason) }, + ) + } } @Test diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index cf84a03f1..e6c178632 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -1,7 +1,9 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.contracts.ContractAcquisitionFailureKind +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract @@ -18,9 +20,9 @@ import kotlinx.coroutines.CancellationException */ fun acquireAppStoreContract( request: ContractAcquisitionRequest, - acquire: (ContractAcquisitionRequest) -> VerifiedOpenApiContract?, -): AcquiredOpenApiContract? { - val contract = try { + acquire: (ContractAcquisitionRequest) -> ContractAcquisitionOutcome, +): AppStoreContractAcquisition { + val outcome = try { acquire(request) } catch (cancelled: CancellationException) { throw cancelled @@ -29,7 +31,17 @@ fun acquireAppStoreContract( } catch (failure: LinkageError) { throw AppStoreContractAcquisitionException(AppStoreContractFailureKind.RuntimeIncompatible, failure) } - return contract?.toAcquiredOpenApiContract() + return when (outcome) { + is ContractAcquisitionOutcome.Acquired -> AppStoreContractAcquisition.Acquired(outcome.contract.toAcquiredOpenApiContract()) + is ContractAcquisitionOutcome.Unavailable -> AppStoreContractAcquisition.Unavailable(outcome.reason.toAppStoreReason()) + } +} + +private fun ContractUnavailableReason.toAppStoreReason(): AppStoreContractUnavailableReason = when (this) { + ContractUnavailableReason.NoMatchingRelease -> AppStoreContractUnavailableReason.NoMatchingRelease + ContractUnavailableReason.SourceTemporarilyUnavailable -> + AppStoreContractUnavailableReason.SourceTemporarilyUnavailable + ContractUnavailableReason.NoUsableContract -> AppStoreContractUnavailableReason.NoUsableContract } private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFailureKind = when (this) { From 830f783b4e27d504c556f42ffe0fe2ad8cd9326c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 15 Oct 2026 03:38:32 +0200 Subject: [PATCH 09/49] test(android): cover every contract facade and the real catalog path List the new catalog cache, trust verifier, and YAML parser facades in the Android initializer test, and exercise the real catalog path with a failing cache and YAML parsing on the Android runtime. A JVM unit test loads every packaged facade and fails when one is missing from that list. --- .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 54 ++++++++++++++++++- .../ContractModuleInitializationTest.kt | 44 +++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ContractModuleInitializationTest.kt diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index a4cf5e8fb..934e1de76 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -1,11 +1,21 @@ package dev.obiente.nextcloudnative import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition import dev.obiente.nextcloudnative.app.AppStoreContractAcquisitionException import dev.obiente.nextcloudnative.app.AppStoreContractFailureKind +import dev.obiente.nextcloudnative.app.AppStoreContractUnavailableReason import dev.obiente.nextcloudnative.app.acquireAppStoreContract import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache +import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer +import java.io.File import java.lang.reflect.Method +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -42,9 +52,11 @@ class AppOwnedOpenApiAndroidInstrumentedTest { @Test fun everyContractStaticInitializerAndFailureBoundaryWorkOnAndroid() { - // Initialize each packaged top-level facade and companion owner on ART and ICU. + // Initialize each packaged top-level facade and companion owner on ART and ICU. The JVM + // unit test ContractModuleInitializationTest fails when a facade is missing from this list. listOf( - "AppOwnedOpenApiContractKt", "ContractAcquisitionFailureKt", "SignedAppStoreContractAcquirerKt", + "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "ContractAcquisitionFailureKt", + "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", "FileAppStoreCatalogCache", "DynamicApiResponseCache", "FileVerifiedContractCache", @@ -63,6 +75,44 @@ class AppOwnedOpenApiAndroidInstrumentedTest { assertFalse(failed?.message.orEmpty().contains("SyntheticKt")) } + @Test + fun realCatalogPathAndYamlParserWorkOnAndroid() { + val cacheRoot = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "catalog-cache-test") + cacheRoot.deleteRecursively() + // A regular file where the cache directory belongs makes catalog publication fail on ART. + check(cacheRoot.mkdirs() && File(cacheRoot, "blocked").createNewFile()) + try { + MockWebServer().use { server -> + server.start() + repeat(2) { server.enqueue(MockResponse(body = """[{"id":"other","certificate":"c","releases":[]}]""")) } + val acquirer = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + catalogCache = FileAppStoreCatalogCache(File(File(cacheRoot, "blocked"), "catalogs")), + ) + val outcome = acquireAppStoreContract( + ContractAcquisitionRequest("example", "34.0.3", "1.0.0"), + acquirer::acquireOutcome, + ) + assertEquals( + AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoMatchingRelease), + outcome, + ) + assertEquals(2, server.requestCount) + } + } finally { + cacheRoot.deleteRecursively() + } + + val parseYaml = Class.forName("dev.obiente.nextcloudnative.contracts.OpenApiYamlParserKt") + .getDeclaredMethod("parseYamlObject", String::class.java) + val yaml = "openapi: 3.0.1\nservers: [{ url: /apps/example }]\npaths:\n /items: {}\n" + val document = parseYaml.invoke(null, yaml) as JSONObject + assertEquals("3.0.1", document.optString("openapi")) + assertTrue(document.getJSONObject("paths").has("/items")) + } + private fun Method.accepts(server: String): Boolean { val document = """{"openapi":"3.1.1","servers":[{"url":"$server"}],"paths":{"/items":{}}}""" return invoke(null, "example", "openapi.json", document) as Boolean diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ContractModuleInitializationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ContractModuleInitializationTest.kt new file mode 100644 index 000000000..01571d4cc --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ContractModuleInitializationTest.kt @@ -0,0 +1,44 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import java.io.File +import java.util.jar.JarFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Loads every packaged contract-module facade on the JVM and keeps the Android instrumented + * initializer list exhaustive. This complements, and does not replace, the run on ART and ICU. + */ +class ContractModuleInitializationTest { + @Test + fun everyContractFacadeInitializesAndIsListedForAndroidRuntimeCoverage() { + val facades = packagedFacades() + assertTrue(facades.size >= 10, "The contract module facades were not found: $facades") + facades.forEach { name -> Class.forName("$CONTRACT_PACKAGE.$name") } + val instrumented = File(INSTRUMENTED_TEST).readText() + assertEquals(emptyList(), facades.filterNot { name -> "\"$name\"" in instrumented }) + } + + private fun packagedFacades(): List { + val location = File(ContractAcquisitionRequest::class.java.protectionDomain.codeSource.location.toURI()) + val prefix = CONTRACT_PACKAGE.replace('.', '/') + "/" + val entries = if (location.isDirectory) { + location.walkTopDown().filter(File::isFile).map { it.relativeTo(location).invariantSeparatorsPath }.toList() + } else { + JarFile(location).use { jar -> jar.entries().asSequence().map { it.name }.toList() } + } + return entries + .filter { it.startsWith(prefix) && it.endsWith("Kt.class") && '$' !in it } + .map { it.removePrefix(prefix).removeSuffix(".class") } + .filter { '/' !in it } + .sorted() + } + + private companion object { + const val CONTRACT_PACKAGE = "dev.obiente.nextcloudnative.contracts" + const val INSTRUMENTED_TEST = + "src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt" + } +} From 55063a07bfe9dec9551566dcf2145abdbc0216c7 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:38:22 +0000 Subject: [PATCH 10/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 7d0dd6548..302297885 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -80,6 +80,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt", @@ -514,7 +515,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "0092b8bc6445328384db348a4fd5eb173c5fe4133d170a193fa7c399dcd411ab", - "ui/build.gradle.kts": "191280b7fea632718455742a59c24808d43d3e7725d2fd6d93c4d1cc9ce20b6f", + "ui/build.gradle.kts": "b570c904e56a4961be452b90d186040331788ddf86b2bab63a43616c4cd743f0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPlaybackRetirement.kt": "01069d5a599bbf9dce5c9a152bb771f4bd524de7fb2d74db9c6bf9b774b31cb2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "d247d7d310e8579717e243bce827e8ab6d8a8e5a6df011b481e9bc6fc72da862", @@ -583,8 +584,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "6862bd7d210866f1f45e70c910159ff83029cde3428c59ad554cbb9dd8123603", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "b8eafe05a2db826463e1319f75b0eac900bc6b00a59468702e90870e262b87f0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationCache.kt": "af27d14641fde05a5c1d1688676102c6630ccce4cd8d06ab0e3d58c95432aa8f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", @@ -593,7 +595,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "1b60dcf20f1bc8a1cbb4715db23afc8e5cbb8b5724fdf0157b9c2ec13d10b815", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "3942c5349f3c0d1e49266defe22332d219766320c47d38e4206019717ff6edd4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", @@ -748,12 +750,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "ff7b04865462f28448deff0230f6c817019e95b9f8b95c9cae81d324694c2afc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "c7e2f2d01fcf2291c01c48109c28b45860b7e5966c14c2de88370b6cd18e10c1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "3946e0f9ade815f340b6ec0bad48e07387aaac63ffbbbf0c041c9c36ff949eab", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "cdc4452719adbda03cbe96d646bb095df8e91471d5d3b1986b2a5131d77974f1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "70b83f289d3b5dbe4b6494b62827bb35b3f228066eeaf514092c0f9a6bfa9de1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "f017fa7ab65c626963530805fd99fb7f3d2b43597b144910303fda201c558fc7", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5076b1ad4aa9368f6c64dca000dea383120886bc518ee061df36916677d30298", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "0d1605e5c2cfc43ef45d0ed4c20a7260d09b254efe1231bd21c65c36884ae69f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "df00a758c8a29e9d1b221911a03f31547dc3510816da6228f8d38c607d0f4092", From 0b3de15ba9b6d6b74dfc6d500374ea44fec6b06b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 00:58:57 +0200 Subject: [PATCH 11/49] refactor(contracts): move App Store catalog release selection into its own file --- .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 3 +- .../contracts/AppStoreCatalogReleases.kt | 239 ++++++++++++++++++ .../SignedAppStoreContractAcquirer.kt | 233 ----------------- tools/kotlin-file-size-baseline.txt | 2 +- 4 files changed, 242 insertions(+), 235 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 934e1de76..58f475898 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -55,7 +55,8 @@ class AppOwnedOpenApiAndroidInstrumentedTest { // Initialize each packaged top-level facade and companion owner on ART and ICU. The JVM // unit test ContractModuleInitializationTest fails when a facade is missing from this list. listOf( - "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "ContractAcquisitionFailureKt", + "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "AppStoreCatalogReleasesKt", + "ContractAcquisitionFailureKt", "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt new file mode 100644 index 000000000..4a7958548 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -0,0 +1,239 @@ +package dev.obiente.nextcloudnative.contracts + +import java.net.URI +import org.json.JSONArray +import org.json.JSONObject + +internal fun selectRelease( + catalogJson: String, + request: ContractAcquisitionRequest, + catalogUrl: String, + requireOfficialGitHubHost: Boolean = true, +): AppStoreRelease? { + val candidates = parseReleases( + catalogJson = catalogJson, + appId = request.appId, + catalogUrl = catalogUrl, + requireOfficialGitHubHost = requireOfficialGitHubHost, + ).filter { release -> + request.installedAppVersion != null || !release.version.isRecognizedPrerelease() + }.filter { release -> + request.installedAppVersion == null || release.version == request.installedAppVersion + } + return candidates.maxWithOrNull { left, right -> + compareSemanticVersions(left.version, right.version) + } +} + +internal fun parseReleases( + catalogJson: String, + appId: String, + catalogUrl: String, + requireOfficialGitHubHost: Boolean, +): List { + val apps = JSONArray(catalogJson) + val app = (0 until apps.length()) + .asSequence() + .mapNotNull(apps::optJSONObject) + .firstOrNull { candidate -> candidate.optString("id") == appId } + ?: return emptyList() + val certificate = app.optString("certificate").takeIf(String::isNotBlank) ?: return emptyList() + val releases = app.optJSONArray("releases") ?: return emptyList() + return (0 until releases.length()) + .asSequence() + .mapNotNull(releases::optJSONObject) + .filter { release -> !release.optBoolean("isNightly", false) } + .mapNotNull { release -> + val version = release.optString("version").takeIf(String::isNotBlank) ?: return@mapNotNull null + val download = release.optString("download").takeIf(String::isNotBlank) ?: return@mapNotNull null + val signature = release.optString("signature").takeIf(String::isNotBlank) ?: return@mapNotNull null + val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) ?: return@mapNotNull null + AppStoreRelease( + appId = appId, + version = version, + downloadUrl = download, + signature = signature, + signatureDigest = digest, + certificatePem = certificate, + catalogUrl = catalogUrl, + platformVersionSpec = release.optString("platformVersionSpec").takeIf(String::isNotBlank), + githubSources = deriveGitHubSources( + app = app, + downloadUrl = download, + releaseVersion = version, + requireOfficialReleaseHost = requireOfficialGitHubHost, + ), + ) + } + .toList() +} + +private data class ParsedVersion( + val numeric: List, + val suffix: String, +) + +internal fun normalizeServerVersion(version: String): String { + val parsed = parseVersion(version) + requireValidRequest(parsed != null && parsed.numeric.size >= 3) { + "The server version must contain at least three numeric components." + } + return requireNotNull(parsed).numeric.take(3).joinToString(".") +} + +private fun parseVersion(version: String): ParsedVersion? { + if (version.isBlank() || !version.first().isDigit()) return null + val numeric = mutableListOf() + var index = 0 + while (index < version.length) { + val start = index + while (index < version.length && version[index].isDigit()) index += 1 + if (start == index) break + numeric += version.substring(start, index).toIntOrNull() ?: return null + if (index >= version.length || version[index] != '.') break + if (index + 1 >= version.length || !version[index + 1].isDigit()) break + index += 1 + } + if (numeric.isEmpty()) return null + return ParsedVersion(numeric, version.substring(index)) +} + +private fun String.isRecognizedPrerelease(): Boolean { + val suffix = parseVersion(this)?.suffix?.lowercase().orEmpty() + return PRE_RELEASE_MARKERS.any(suffix::contains) +} + +internal fun compatibleReleaseCandidates( + releases: List, + installedVersion: String?, +): List { + if (installedVersion == null) { + return releases + .filterNot { release -> release.version.isRecognizedPrerelease() } + .sortedWith { left, right -> compareSemanticVersions(right.version, left.version) } + } + val installed = parseVersion(installedVersion) ?: return emptyList() + if (installed.numeric.size < 2 || installedVersion.isRecognizedPrerelease()) return emptyList() + return releases + .filterNot { release -> release.version.isRecognizedPrerelease() } + .filter { release -> + val candidate = parseVersion(release.version) + candidate != null && candidate.numeric.size >= 2 && + candidate.numeric[0] == installed.numeric[0] && + candidate.numeric[1] == installed.numeric[1] + } + .sortedWith { left, right -> compareCompatibility(left.version, right.version, installedVersion) } +} + +private fun compareCompatibility(left: String, right: String, installed: String): Int { + val installedVersion = parseVersion(installed) ?: return left.compareTo(right) + val leftVersion = parseVersion(left) ?: return 1 + val rightVersion = parseVersion(right) ?: return -1 + val leftPatch = leftVersion.numeric.getOrElse(2) { 0 } + val rightPatch = rightVersion.numeric.getOrElse(2) { 0 } + val installedPatch = installedVersion.numeric.getOrElse(2) { 0 } + val leftIsPrior = leftPatch <= installedPatch + val rightIsPrior = rightPatch <= installedPatch + if (leftIsPrior != rightIsPrior) return if (leftIsPrior) -1 else 1 + return if (leftIsPrior) rightPatch.compareTo(leftPatch) else leftPatch.compareTo(rightPatch) +} + +private data class GitHubRepository(val owner: String, val repository: String) + +private data class GitHubReleaseLocation( + val repository: GitHubRepository, + val tag: String, +) + +private fun deriveGitHubSources( + app: JSONObject, + downloadUrl: String, + releaseVersion: String, + requireOfficialReleaseHost: Boolean, +): List { + val release = parseGitHubReleaseLocation(downloadUrl, requireOfficialReleaseHost) ?: return emptyList() + val repositories = buildList { + parseGitHubRepository(app.optString("repository"))?.let(::add) + parseGitHubRepository(app.optString("website"))?.let(::add) + add(release.repository) + }.distinct() + val conventionalTags = setOf(releaseVersion, "v$releaseVersion") + val tags = (if (release.tag in conventionalTags) { + listOf(release.tag) + } else { + listOf(release.tag, "v$releaseVersion", releaseVersion) + }) + .distinct() + .filter { tag -> tag.matches(GITHUB_TAG_PATTERN) } + return repositories.flatMap { repository -> + tags.map { tag -> GitHubSourceReference(repository.owner, repository.repository, tag) } + } +} + +private fun parseGitHubRepository(url: String): GitHubRepository? { + val uri = runCatching { URI(url) }.getOrNull() ?: return null + if (!uri.scheme.equals("https", ignoreCase = true) || !uri.host.equals("github.com", ignoreCase = true)) { + return null + } + if ( + uri.userInfo != null || + uri.port != -1 || + uri.query != null || + uri.fragment?.takeIf(String::isNotBlank)?.equals("readme", ignoreCase = true) == false + ) { + return null + } + val parts = uri.rawPath.trim('/').split('/').filter(String::isNotBlank) + if (parts.size != 2) return null + val owner = parts[0] + val repository = parts[1].removeSuffix(".git") + if (!owner.matches(GITHUB_REPOSITORY_COMPONENT) || !repository.matches(GITHUB_REPOSITORY_COMPONENT)) return null + return GitHubRepository(owner, repository) +} + +private fun parseGitHubReleaseLocation( + url: String, + requireOfficialHost: Boolean, +): GitHubReleaseLocation? { + val uri = runCatching { URI(url) }.getOrNull() ?: return null + if ( + requireOfficialHost && + (!uri.scheme.equals("https", ignoreCase = true) || + !uri.host.equals("github.com", ignoreCase = true) || + uri.port != -1) + ) { + return null + } + if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null + val parts = uri.rawPath.trim('/').split('/').filter(String::isNotBlank) + if (parts.size < 6 || parts[2] != "releases" || parts[3] != "download") return null + val owner = parts[0] + val repository = parts[1].removeSuffix(".git") + val tag = parts[4] + if ( + !owner.matches(GITHUB_REPOSITORY_COMPONENT) || + !repository.matches(GITHUB_REPOSITORY_COMPONENT) || + !tag.matches(GITHUB_TAG_PATTERN) + ) { + return null + } + return GitHubReleaseLocation(GitHubRepository(owner, repository), tag) +} + +internal fun compareSemanticVersions(left: String, right: String): Int { + val leftVersion = parseVersion(left) ?: return left.compareTo(right) + val rightVersion = parseVersion(right) ?: return left.compareTo(right) + repeat(maxOf(leftVersion.numeric.size, rightVersion.numeric.size)) { index -> + val difference = (leftVersion.numeric.getOrNull(index) ?: 0) + .compareTo(rightVersion.numeric.getOrNull(index) ?: 0) + if (difference != 0) return difference + } + if (leftVersion.suffix.isBlank() != rightVersion.suffix.isBlank()) { + return if (leftVersion.suffix.isBlank()) 1 else -1 + } + return leftVersion.suffix.compareTo(rightVersion.suffix) +} + +private val GITHUB_REPOSITORY_COMPONENT = Regex("[A-Za-z0-9_.-]+") +private val GITHUB_TAG_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9._+-]*") +private val PRE_RELEASE_MARKERS = listOf("alpha", "beta", "rc", "dev", "nightly") diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 4d24f7a03..c462575ea 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -514,236 +514,6 @@ private fun defaultContractHttpClient(): OkHttpClient = OkHttpClient.Builder() .callTimeout(120, TimeUnit.SECONDS) .build() -internal fun selectRelease( - catalogJson: String, - request: ContractAcquisitionRequest, - catalogUrl: String, - requireOfficialGitHubHost: Boolean = true, -): AppStoreRelease? { - val candidates = parseReleases( - catalogJson = catalogJson, - appId = request.appId, - catalogUrl = catalogUrl, - requireOfficialGitHubHost = requireOfficialGitHubHost, - ).filter { release -> - request.installedAppVersion != null || !release.version.isRecognizedPrerelease() - }.filter { release -> - request.installedAppVersion == null || release.version == request.installedAppVersion - } - return candidates.maxWithOrNull { left, right -> - compareSemanticVersions(left.version, right.version) - } -} - -private fun parseReleases( - catalogJson: String, - appId: String, - catalogUrl: String, - requireOfficialGitHubHost: Boolean, -): List { - val apps = JSONArray(catalogJson) - val app = (0 until apps.length()) - .asSequence() - .mapNotNull(apps::optJSONObject) - .firstOrNull { candidate -> candidate.optString("id") == appId } - ?: return emptyList() - val certificate = app.optString("certificate").takeIf(String::isNotBlank) ?: return emptyList() - val releases = app.optJSONArray("releases") ?: return emptyList() - return (0 until releases.length()) - .asSequence() - .mapNotNull(releases::optJSONObject) - .filter { release -> !release.optBoolean("isNightly", false) } - .mapNotNull { release -> - val version = release.optString("version").takeIf(String::isNotBlank) ?: return@mapNotNull null - val download = release.optString("download").takeIf(String::isNotBlank) ?: return@mapNotNull null - val signature = release.optString("signature").takeIf(String::isNotBlank) ?: return@mapNotNull null - val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) ?: return@mapNotNull null - AppStoreRelease( - appId = appId, - version = version, - downloadUrl = download, - signature = signature, - signatureDigest = digest, - certificatePem = certificate, - catalogUrl = catalogUrl, - platformVersionSpec = release.optString("platformVersionSpec").takeIf(String::isNotBlank), - githubSources = deriveGitHubSources( - app = app, - downloadUrl = download, - releaseVersion = version, - requireOfficialReleaseHost = requireOfficialGitHubHost, - ), - ) - } - .toList() -} - -private data class ParsedVersion( - val numeric: List, - val suffix: String, -) - -private fun normalizeServerVersion(version: String): String { - val parsed = parseVersion(version) - requireValidRequest(parsed != null && parsed.numeric.size >= 3) { - "The server version must contain at least three numeric components." - } - return requireNotNull(parsed).numeric.take(3).joinToString(".") -} - -private fun parseVersion(version: String): ParsedVersion? { - if (version.isBlank() || !version.first().isDigit()) return null - val numeric = mutableListOf() - var index = 0 - while (index < version.length) { - val start = index - while (index < version.length && version[index].isDigit()) index += 1 - if (start == index) break - numeric += version.substring(start, index).toIntOrNull() ?: return null - if (index >= version.length || version[index] != '.') break - if (index + 1 >= version.length || !version[index + 1].isDigit()) break - index += 1 - } - if (numeric.isEmpty()) return null - return ParsedVersion(numeric, version.substring(index)) -} - -private fun String.isRecognizedPrerelease(): Boolean { - val suffix = parseVersion(this)?.suffix?.lowercase().orEmpty() - return PRE_RELEASE_MARKERS.any(suffix::contains) -} - -private fun compatibleReleaseCandidates( - releases: List, - installedVersion: String?, -): List { - if (installedVersion == null) { - return releases - .filterNot { release -> release.version.isRecognizedPrerelease() } - .sortedWith { left, right -> compareSemanticVersions(right.version, left.version) } - } - val installed = parseVersion(installedVersion) ?: return emptyList() - if (installed.numeric.size < 2 || installedVersion.isRecognizedPrerelease()) return emptyList() - return releases - .filterNot { release -> release.version.isRecognizedPrerelease() } - .filter { release -> - val candidate = parseVersion(release.version) - candidate != null && candidate.numeric.size >= 2 && - candidate.numeric[0] == installed.numeric[0] && - candidate.numeric[1] == installed.numeric[1] - } - .sortedWith { left, right -> compareCompatibility(left.version, right.version, installedVersion) } -} - -private fun compareCompatibility(left: String, right: String, installed: String): Int { - val installedVersion = parseVersion(installed) ?: return left.compareTo(right) - val leftVersion = parseVersion(left) ?: return 1 - val rightVersion = parseVersion(right) ?: return -1 - val leftPatch = leftVersion.numeric.getOrElse(2) { 0 } - val rightPatch = rightVersion.numeric.getOrElse(2) { 0 } - val installedPatch = installedVersion.numeric.getOrElse(2) { 0 } - val leftIsPrior = leftPatch <= installedPatch - val rightIsPrior = rightPatch <= installedPatch - if (leftIsPrior != rightIsPrior) return if (leftIsPrior) -1 else 1 - return if (leftIsPrior) rightPatch.compareTo(leftPatch) else leftPatch.compareTo(rightPatch) -} - -private data class GitHubRepository(val owner: String, val repository: String) - -private data class GitHubReleaseLocation( - val repository: GitHubRepository, - val tag: String, -) - -private fun deriveGitHubSources( - app: JSONObject, - downloadUrl: String, - releaseVersion: String, - requireOfficialReleaseHost: Boolean, -): List { - val release = parseGitHubReleaseLocation(downloadUrl, requireOfficialReleaseHost) ?: return emptyList() - val repositories = buildList { - parseGitHubRepository(app.optString("repository"))?.let(::add) - parseGitHubRepository(app.optString("website"))?.let(::add) - add(release.repository) - }.distinct() - val conventionalTags = setOf(releaseVersion, "v$releaseVersion") - val tags = (if (release.tag in conventionalTags) { - listOf(release.tag) - } else { - listOf(release.tag, "v$releaseVersion", releaseVersion) - }) - .distinct() - .filter { tag -> tag.matches(GITHUB_TAG_PATTERN) } - return repositories.flatMap { repository -> - tags.map { tag -> GitHubSourceReference(repository.owner, repository.repository, tag) } - } -} - -private fun parseGitHubRepository(url: String): GitHubRepository? { - val uri = runCatching { URI(url) }.getOrNull() ?: return null - if (!uri.scheme.equals("https", ignoreCase = true) || !uri.host.equals("github.com", ignoreCase = true)) { - return null - } - if ( - uri.userInfo != null || - uri.port != -1 || - uri.query != null || - uri.fragment?.takeIf(String::isNotBlank)?.equals("readme", ignoreCase = true) == false - ) { - return null - } - val parts = uri.rawPath.trim('/').split('/').filter(String::isNotBlank) - if (parts.size != 2) return null - val owner = parts[0] - val repository = parts[1].removeSuffix(".git") - if (!owner.matches(GITHUB_REPOSITORY_COMPONENT) || !repository.matches(GITHUB_REPOSITORY_COMPONENT)) return null - return GitHubRepository(owner, repository) -} - -private fun parseGitHubReleaseLocation( - url: String, - requireOfficialHost: Boolean, -): GitHubReleaseLocation? { - val uri = runCatching { URI(url) }.getOrNull() ?: return null - if ( - requireOfficialHost && - (!uri.scheme.equals("https", ignoreCase = true) || - !uri.host.equals("github.com", ignoreCase = true) || - uri.port != -1) - ) { - return null - } - if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null - val parts = uri.rawPath.trim('/').split('/').filter(String::isNotBlank) - if (parts.size < 6 || parts[2] != "releases" || parts[3] != "download") return null - val owner = parts[0] - val repository = parts[1].removeSuffix(".git") - val tag = parts[4] - if ( - !owner.matches(GITHUB_REPOSITORY_COMPONENT) || - !repository.matches(GITHUB_REPOSITORY_COMPONENT) || - !tag.matches(GITHUB_TAG_PATTERN) - ) { - return null - } - return GitHubReleaseLocation(GitHubRepository(owner, repository), tag) -} - -internal fun compareSemanticVersions(left: String, right: String): Int { - val leftVersion = parseVersion(left) ?: return left.compareTo(right) - val rightVersion = parseVersion(right) ?: return left.compareTo(right) - repeat(maxOf(leftVersion.numeric.size, rightVersion.numeric.size)) { index -> - val difference = (leftVersion.numeric.getOrNull(index) ?: 0) - .compareTo(rightVersion.numeric.getOrNull(index) ?: 0) - if (difference != 0) return difference - } - if (leftVersion.suffix.isBlank() != rightVersion.suffix.isBlank()) { - return if (leftVersion.suffix.isBlank()) 1 else -1 - } - return leftVersion.suffix.compareTo(rightVersion.suffix) -} - class OpenApiContractMissingException(message: String) : IllegalStateException(message) internal fun extractOpenApiContract( @@ -1035,9 +805,6 @@ private const val MAX_OPEN_API_CANDIDATES = 64 private const val MAX_EXTERNAL_SCHEMA_DOCUMENTS = 16 private const val MAX_EXTERNAL_REFERENCE_DEPTH = 24 private val OPEN_API_FILE_PATTERN = Regex("(?i)openapi(?:[-_][A-Za-z0-9][A-Za-z0-9._-]*)?\\.(?:json|yaml|yml)") -private val GITHUB_REPOSITORY_COMPONENT = Regex("[A-Za-z0-9_.-]+") private val SAFE_REPOSITORY_PATH_SEGMENT = Regex("[A-Za-z0-9_.-]+") -private val GITHUB_TAG_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9._+-]*") -private val PRE_RELEASE_MARKERS = listOf("alpha", "beta", "rc", "dev", "nightly") private val TAR_METADATA_TYPES = setOf('g', 'x', 'K') private const val MAX_TAR_PATH_BYTES = 4_096L diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index cab3e7783..7b0024030 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1043 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|810 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 From bbdc51d55f1e403898df5fcb389db4d1d5f4b8f3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 01:13:49 +0200 Subject: [PATCH 12/49] refactor: move same-origin dynamic contract discovery into its own file --- tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicNativeRuntime.kt | 211 +---------------- .../app/DynamicSameOriginDiscovery.kt | 221 ++++++++++++++++++ 3 files changed, 224 insertions(+), 210 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 7b0024030..5001f8d4b 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -9,7 +9,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|11 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|864 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1801 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2436 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2229 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2515 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index faae2db86..250cc1711 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -57,7 +57,7 @@ import kotlinx.serialization.json.put import kotlin.math.ceil import kotlin.math.floor -private val dynamicJson = Json { +internal val dynamicJson = Json { ignoreUnknownKeys = true isLenient = false explicitNulls = false @@ -353,211 +353,6 @@ internal fun NextcloudAppEntry.canonicalAppStoreId(): String { private fun String.coreVersionOrNull(): String? = Regex("([0-9]+)\\.([0-9]+)\\.([0-9]+)").find(this)?.value -internal suspend fun discoverDynamicAppDescriptor( - serverUrl: String, - app: NextcloudAppEntry, - execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, - onProgress: (DynamicDescriptorDiscoveryProgress) -> Unit = {}, -): DynamicDescriptorDiscovery { - require(app.id.matches(Regex("[A-Za-z0-9_.-]+"))) { "The app ID is invalid." } - val origin = serverUrl.httpOrigin() - val policy = EndpointPolicy( - serverOrigin = origin, - approvedApiPrefixes = listOf( - "/ocs/v1.php/apps/${app.id}", - "/ocs/v2.php/apps/${app.id}", - "/index.php/apps/${app.id}", - ), - ) - val identity = AppIdentity(app.id, app.name, "server-installed") - val compiler = DynamicAppDescriptorCompiler() - val diagnostics = mutableListOf() - - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.ServerApiCatalog, - "Checking the server API catalog", - ), - ) - val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics) - viewerSpecs.forEach { specId -> - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.AppApiDefinition, - "Reading the app's API definition", - ), - ) - val path = "$OCS_API_VIEWER_SPEC_PATH/${specId.encodeUrlComponent()}" - val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(path)) } - .onFailure { failure -> - diagnostics += "OCS API Viewer could not load $specId: ${failure.message ?: "request failed"}." - } - .getOrNull() ?: return@forEach - if (response.status !in 200..299) { - diagnostics += "OCS API Viewer could not load $specId (HTTP ${response.status})." - return@forEach - } - val document = response.parseOpenApiDocument() - if (document == null) { - diagnostics += "OCS API Viewer returned an invalid OpenAPI JSON body for $specId${response.contentTypeDiagnostic()}." - return@forEach - } - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.NativeWorkspace, - "Preparing the native workspace", - ), - ) - val descriptor = runCatchingPreservingCancellation { - compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) - }.onFailure { failure -> - diagnostics += "OCS API Viewer returned an unusable specification for $specId: " + - (failure.message ?: "compilation failed") + "." - }.getOrNull() ?: return@forEach - return DynamicDescriptorDiscovery( - descriptor = descriptor, - sourcePath = path, - acquisition = DynamicDescriptorAcquisition.OcsApiViewer, - diagnostics = diagnostics, - ) - } - - val staticCandidates = buildList { - listOf("openapi.json", "openapi-full.json", "openapi-public.json").forEach { file -> - add("/apps/${app.id}/$file") - add("/custom_apps/${app.id}/$file") - } - } - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.AppApiDefinition, - "Reading the app's API definition", - ), - ) - staticCandidates.forEach { path -> - val response = runCatchingPreservingCancellation { - execute(dynamicDiscoveryRequest(path)) - }.getOrNull() ?: return@forEach - if (response.status !in 200..299 || response.contentType?.contains("json", ignoreCase = true) != true) { - return@forEach - } - val document = response.parseOpenApiDocument() ?: return@forEach - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.NativeWorkspace, - "Preparing the native workspace", - ), - ) - val descriptor = runCatchingPreservingCancellation { - compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) - }.getOrNull() ?: return@forEach - return DynamicDescriptorDiscovery( - descriptor = descriptor, - sourcePath = path, - acquisition = DynamicDescriptorAcquisition.StaticAppAsset, - diagnostics = diagnostics, - ) - } - - diagnostics += "No valid static OpenAPI document was found in the app's advertised asset locations." - - onProgress( - DynamicDescriptorDiscoveryProgress( - DynamicDescriptorDiscoveryPhase.NativeWorkspace, - "Preparing the native workspace", - ), - ) - return DynamicDescriptorDiscovery( - descriptor = compiler.compile( - DynamicDiscoveryInput( - app = identity, - endpointPolicy = policy, - ), - ).requireValid(), - sourcePath = null, - acquisition = DynamicDescriptorAcquisition.MetadataFallback, - diagnostics = diagnostics, - ) -} - -private suspend fun discoverOfficialViewerSpecIds( - appId: String, - execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, - diagnostics: MutableList, -): List { - val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(OCS_API_VIEWER_CATALOG_PATH)) } - .onFailure { failure -> - diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint: " + - (failure.message ?: "request failed") + "." - } - .getOrNull() ?: return emptyList() - if (response.status == 404) { - diagnostics += "OCS API Viewer is not installed or enabled for this user (HTTP 404 at $OCS_API_VIEWER_CATALOG_PATH)." - return emptyList() - } - if (response.status !in 200..299) { - diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint (HTTP ${response.status})." - return emptyList() - } - val catalog = runCatchingPreservingCancellation { dynamicJson.parseToJsonElement(response.body.decodeToString()) as? JsonArray } - .getOrNull() - if (catalog == null) { - diagnostics += "OCS API Viewer returned an invalid app catalog${response.contentTypeDiagnostic()}." - return emptyList() - } - val appEntry = catalog - .mapNotNull { it as? JsonObject } - .firstOrNull { entry -> (entry["id"] as? JsonPrimitive)?.contentOrNull == appId } - if (appEntry == null) { - diagnostics += "OCS API Viewer does not advertise an OpenAPI specification for $appId." - return emptyList() - } - val specs = (appEntry["specs"] as? JsonArray) - .orEmpty() - .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } - .filter { it.matches(Regex("[A-Za-z0-9_.-]+")) && (it == appId || it.startsWith("$appId-")) } - .distinct() - .sortedWith(compareBy { it != appId }.thenBy { it }) - if (specs.isEmpty()) { - diagnostics += "OCS API Viewer advertised $appId without a usable specification ID." - } - return specs -} - -private fun dynamicDiscoveryRequest(path: String): NextcloudApiRequest = NextcloudApiRequest( - method = NextcloudApiMethod.GET, - relativePath = path, - maximumResponseBytes = DEFAULT_DYNAMIC_API_RESPONSE_LIMIT_BYTES, -) - -private fun NextcloudApiResponse.parseOpenApiDocument(): JsonObject? { - val document = runCatching { dynamicJson.parseToJsonElement(body.decodeToString()) as? JsonObject } - .getOrNull() ?: return null - val version = (document["openapi"] as? JsonPrimitive)?.contentOrNull - return document.takeIf { version?.startsWith("3.") == true } -} - -private fun NextcloudApiResponse.contentTypeDiagnostic(): String = - contentType?.let { " (Content-Type: $it)" } ?: " (without a Content-Type header)" - -private fun compileDynamicDescriptor( - compiler: DynamicAppDescriptorCompiler, - identity: AppIdentity, - policy: EndpointPolicy, - serverUrl: String, - path: String, - document: JsonObject, -): DynamicAppDescriptor = compiler.compile( - DynamicDiscoveryInput( - app = identity, - endpointPolicy = policy, - advertisedOpenApi = AdvertisedOpenApi( - documentUrl = serverUrl.trimEnd('/') + path, - document = document, - ), - ), -).requireValid() - class DynamicNextcloudActionExecutor( private val services: NextcloudPlatformServices, private val session: NextcloudSession, @@ -2418,7 +2213,7 @@ internal fun String.httpOrigin(): String { return match.groupValues[1] } -private fun String.encodeUrlComponent(): String = buildString { +internal fun String.encodeUrlComponent(): String = buildString { for (byte in this@encodeUrlComponent.encodeToByteArray()) { val unsigned = byte.toInt() and 0xff val unreserved = unsigned in 'a'.code..'z'.code || unsigned in 'A'.code..'Z'.code || @@ -2432,5 +2227,3 @@ private fun String.encodeUrlComponent(): String = buildString { } private const val DYNAMIC_HEX = "0123456789ABCDEF" -private const val OCS_API_VIEWER_CATALOG_PATH = "/index.php/apps/ocs_api_viewer/apps" -private const val OCS_API_VIEWER_SPEC_PATH = "/index.php/apps/ocs_api_viewer/apps" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt new file mode 100644 index 000000000..4d8b39a9b --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -0,0 +1,221 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.AdvertisedOpenApi +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptorCompiler +import dev.obiente.nextcloudnative.nativeui.model.DynamicDiscoveryInput +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy +import dev.obiente.nextcloudnative.nativeui.model.requireValid +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +internal suspend fun discoverDynamicAppDescriptor( + serverUrl: String, + app: NextcloudAppEntry, + execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, + onProgress: (DynamicDescriptorDiscoveryProgress) -> Unit = {}, +): DynamicDescriptorDiscovery { + require(app.id.matches(Regex("[A-Za-z0-9_.-]+"))) { "The app ID is invalid." } + val origin = serverUrl.httpOrigin() + val policy = EndpointPolicy( + serverOrigin = origin, + approvedApiPrefixes = listOf( + "/ocs/v1.php/apps/${app.id}", + "/ocs/v2.php/apps/${app.id}", + "/index.php/apps/${app.id}", + ), + ) + val identity = AppIdentity(app.id, app.name, "server-installed") + val compiler = DynamicAppDescriptorCompiler() + val diagnostics = mutableListOf() + + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.ServerApiCatalog, + "Checking the server API catalog", + ), + ) + val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics) + viewerSpecs.forEach { specId -> + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.AppApiDefinition, + "Reading the app's API definition", + ), + ) + val path = "$OCS_API_VIEWER_SPEC_PATH/${specId.encodeUrlComponent()}" + val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(path)) } + .onFailure { failure -> + diagnostics += "OCS API Viewer could not load $specId: ${failure.message ?: "request failed"}." + } + .getOrNull() ?: return@forEach + if (response.status !in 200..299) { + diagnostics += "OCS API Viewer could not load $specId (HTTP ${response.status})." + return@forEach + } + val document = response.parseOpenApiDocument() + if (document == null) { + diagnostics += "OCS API Viewer returned an invalid OpenAPI JSON body for $specId${response.contentTypeDiagnostic()}." + return@forEach + } + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.NativeWorkspace, + "Preparing the native workspace", + ), + ) + val descriptor = runCatchingPreservingCancellation { + compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) + }.onFailure { failure -> + diagnostics += "OCS API Viewer returned an unusable specification for $specId: " + + (failure.message ?: "compilation failed") + "." + }.getOrNull() ?: return@forEach + return DynamicDescriptorDiscovery( + descriptor = descriptor, + sourcePath = path, + acquisition = DynamicDescriptorAcquisition.OcsApiViewer, + diagnostics = diagnostics, + ) + } + + val staticCandidates = buildList { + listOf("openapi.json", "openapi-full.json", "openapi-public.json").forEach { file -> + add("/apps/${app.id}/$file") + add("/custom_apps/${app.id}/$file") + } + } + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.AppApiDefinition, + "Reading the app's API definition", + ), + ) + staticCandidates.forEach { path -> + val response = runCatchingPreservingCancellation { + execute(dynamicDiscoveryRequest(path)) + }.getOrNull() ?: return@forEach + if (response.status !in 200..299 || response.contentType?.contains("json", ignoreCase = true) != true) { + return@forEach + } + val document = response.parseOpenApiDocument() ?: return@forEach + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.NativeWorkspace, + "Preparing the native workspace", + ), + ) + val descriptor = runCatchingPreservingCancellation { + compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) + }.getOrNull() ?: return@forEach + return DynamicDescriptorDiscovery( + descriptor = descriptor, + sourcePath = path, + acquisition = DynamicDescriptorAcquisition.StaticAppAsset, + diagnostics = diagnostics, + ) + } + + diagnostics += "No valid static OpenAPI document was found in the app's advertised asset locations." + + onProgress( + DynamicDescriptorDiscoveryProgress( + DynamicDescriptorDiscoveryPhase.NativeWorkspace, + "Preparing the native workspace", + ), + ) + return DynamicDescriptorDiscovery( + descriptor = compiler.compile( + DynamicDiscoveryInput( + app = identity, + endpointPolicy = policy, + ), + ).requireValid(), + sourcePath = null, + acquisition = DynamicDescriptorAcquisition.MetadataFallback, + diagnostics = diagnostics, + ) +} + +private suspend fun discoverOfficialViewerSpecIds( + appId: String, + execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, + diagnostics: MutableList, +): List { + val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(OCS_API_VIEWER_CATALOG_PATH)) } + .onFailure { failure -> + diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint: " + + (failure.message ?: "request failed") + "." + } + .getOrNull() ?: return emptyList() + if (response.status == 404) { + diagnostics += "OCS API Viewer is not installed or enabled for this user (HTTP 404 at $OCS_API_VIEWER_CATALOG_PATH)." + return emptyList() + } + if (response.status !in 200..299) { + diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint (HTTP ${response.status})." + return emptyList() + } + val catalog = runCatchingPreservingCancellation { dynamicJson.parseToJsonElement(response.body.decodeToString()) as? JsonArray } + .getOrNull() + if (catalog == null) { + diagnostics += "OCS API Viewer returned an invalid app catalog${response.contentTypeDiagnostic()}." + return emptyList() + } + val appEntry = catalog + .mapNotNull { it as? JsonObject } + .firstOrNull { entry -> (entry["id"] as? JsonPrimitive)?.contentOrNull == appId } + if (appEntry == null) { + diagnostics += "OCS API Viewer does not advertise an OpenAPI specification for $appId." + return emptyList() + } + val specs = (appEntry["specs"] as? JsonArray) + .orEmpty() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + .filter { it.matches(Regex("[A-Za-z0-9_.-]+")) && (it == appId || it.startsWith("$appId-")) } + .distinct() + .sortedWith(compareBy { it != appId }.thenBy { it }) + if (specs.isEmpty()) { + diagnostics += "OCS API Viewer advertised $appId without a usable specification ID." + } + return specs +} + +private fun dynamicDiscoveryRequest(path: String): NextcloudApiRequest = NextcloudApiRequest( + method = NextcloudApiMethod.GET, + relativePath = path, + maximumResponseBytes = DEFAULT_DYNAMIC_API_RESPONSE_LIMIT_BYTES, +) + +private fun NextcloudApiResponse.parseOpenApiDocument(): JsonObject? { + val document = runCatching { dynamicJson.parseToJsonElement(body.decodeToString()) as? JsonObject } + .getOrNull() ?: return null + val version = (document["openapi"] as? JsonPrimitive)?.contentOrNull + return document.takeIf { version?.startsWith("3.") == true } +} + +private fun NextcloudApiResponse.contentTypeDiagnostic(): String = + contentType?.let { " (Content-Type: $it)" } ?: " (without a Content-Type header)" + +private fun compileDynamicDescriptor( + compiler: DynamicAppDescriptorCompiler, + identity: AppIdentity, + policy: EndpointPolicy, + serverUrl: String, + path: String, + document: JsonObject, +): DynamicAppDescriptor = compiler.compile( + DynamicDiscoveryInput( + app = identity, + endpointPolicy = policy, + advertisedOpenApi = AdvertisedOpenApi( + documentUrl = serverUrl.trimEnd('/') + path, + document = document, + ), + ), +).requireValid() + +private const val OCS_API_VIEWER_CATALOG_PATH = "/index.php/apps/ocs_api_viewer/apps" +private const val OCS_API_VIEWER_SPEC_PATH = "/index.php/apps/ocs_api_viewer/apps" From 5fc72a495424c7c60fe1145d19760395fd7aaf84 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 01:16:50 +0200 Subject: [PATCH 13/49] fix(contracts): report malformed App Store catalog entries separately Catalog parsing now records which required value made each entry for the requested app unusable. When no usable release matches and a dropped entry would have been selected, acquisition reports a malformed source instead of claiming the App Store lists no matching release. The reason crosses the platform boundary and has its own fallback copy. --- DYNAMIC_APP_DESCRIPTOR.md | 12 +- .../contracts/AppStoreCatalogReleases.kt | 105 ++++++++++---- .../contracts/ContractAcquisitionOutcome.kt | 6 + .../SignedAppStoreContractAcquirer.kt | 17 ++- .../contracts/MalformedCatalogReleaseTest.kt | 134 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicContractFallback.kt | 11 ++ .../DynamicContractFallbackDiscoveryTest.kt | 3 + .../app/JvmAppStoreContractAcquisitionTest.kt | 1 + .../app/JvmAppStoreContractAcquisition.kt | 1 + 10 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 2a654c64c..ae94fdfe4 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -186,6 +186,9 @@ The fallback card derives its title, explanation, and action from that reason: - the inspected releases provide no verified contract for the installed version; - the App Store lists no release matching the installed version; +- the App Store lists the app, but the entry that would be selected lacks a + required value such as its certificate, download, signature, or signature + digest; - the app's linked source refused or rate limited the last fallback request (HTTP 403 or 429), so a contract may still exist; - the server or app version could not be confirmed; @@ -209,9 +212,12 @@ contain exception text, and a fallback reason is never persisted. A completed acquisition without a contract is a typed outcome, not a bare `null`. [`ContractAcquisitionOutcome.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt) -separates no matching release, a temporarily unavailable linked source, and -releases inspected without a usable contract, and the shared JVM boundary -carries that reason to the fallback card. +separates no matching release, a malformed catalog entry, a temporarily +unavailable linked source, and releases inspected without a usable contract, +and the shared JVM boundary carries that reason to the fallback card. Catalog +parsing records which required value made each dropped entry unusable. A +dropped entry counts only when it would have been selected: the exact installed +version, a patch-compatible release, or an entry whose version cannot be read. ## Read failures diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 4a7958548..7fd90d2a7 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -4,6 +4,37 @@ import java.net.URI import org.json.JSONArray import org.json.JSONObject +/** Which required catalog value made an entry for the requested app unusable. */ +internal enum class MalformedCatalogField { Releases, Entry, Version, Certificate, Download, Signature, SignatureDigest } + +/** A catalog entry for the requested app that parsing dropped. A null version could not be read. */ +internal data class MalformedCatalogEntry(val version: String?, val field: MalformedCatalogField) + +/** Usable releases of one app in one catalog, plus the entries that were present but malformed. */ +internal data class CatalogReleases( + val releases: List, + val malformed: List = emptyList(), +) + +/** + * Explains an empty release selection. A dropped entry that would have been selected, or one whose + * version cannot be read, means the catalog is malformed for this app rather than lacking a release. + * Compatible candidates come only from the platform catalog; the full catalog supplies exact matches. + */ +internal fun unmatchedReleaseOutcome( + installedVersion: String?, + platformCatalog: CatalogReleases, + allCatalog: CatalogReleases?, +): ContractAcquisitionOutcome.Unavailable { + fun MalformedCatalogEntry.isExact(): Boolean = version == null || version == installedVersion + val malformedMatch = platformCatalog.malformed.any { entry -> + entry.isExact() || isCompatibleReleaseVersion(requireNotNull(entry.version), installedVersion) + } || allCatalog?.malformed.orEmpty().any { entry -> entry.isExact() } + return ContractAcquisitionOutcome.Unavailable( + if (malformedMatch) ContractUnavailableReason.MalformedSource else ContractUnavailableReason.NoMatchingRelease, + ) +} + internal fun selectRelease( catalogJson: String, request: ContractAcquisitionRequest, @@ -15,7 +46,7 @@ internal fun selectRelease( appId = request.appId, catalogUrl = catalogUrl, requireOfficialGitHubHost = requireOfficialGitHubHost, - ).filter { release -> + ).releases.filter { release -> request.installedAppVersion != null || !release.version.isRecognizedPrerelease() }.filter { release -> request.installedAppVersion == null || release.version == request.installedAppVersion @@ -30,24 +61,43 @@ internal fun parseReleases( appId: String, catalogUrl: String, requireOfficialGitHubHost: Boolean, -): List { +): CatalogReleases { val apps = JSONArray(catalogJson) val app = (0 until apps.length()) .asSequence() .mapNotNull(apps::optJSONObject) .firstOrNull { candidate -> candidate.optString("id") == appId } - ?: return emptyList() - val certificate = app.optString("certificate").takeIf(String::isNotBlank) ?: return emptyList() - val releases = app.optJSONArray("releases") ?: return emptyList() - return (0 until releases.length()) + ?: return CatalogReleases(emptyList()) + val releases = app.optJSONArray("releases") + ?: return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, MalformedCatalogField.Releases))) + val certificate = app.optString("certificate").takeIf(String::isNotBlank) + val malformed = mutableListOf() + val parsed = (0 until releases.length()) .asSequence() - .mapNotNull(releases::optJSONObject) + .mapNotNull { index -> + releases.optJSONObject(index).also { release -> + if (release == null) malformed += MalformedCatalogEntry(null, MalformedCatalogField.Entry) + } + } .filter { release -> !release.optBoolean("isNightly", false) } .mapNotNull { release -> - val version = release.optString("version").takeIf(String::isNotBlank) ?: return@mapNotNull null - val download = release.optString("download").takeIf(String::isNotBlank) ?: return@mapNotNull null - val signature = release.optString("signature").takeIf(String::isNotBlank) ?: return@mapNotNull null - val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) ?: return@mapNotNull null + val version = release.optString("version").takeIf(String::isNotBlank) + val download = release.optString("download").takeIf(String::isNotBlank) + val signature = release.optString("signature").takeIf(String::isNotBlank) + val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) + if (version == null || certificate == null || download == null || signature == null || digest == null) { + malformed += MalformedCatalogEntry( + version = version, + field = when { + version == null -> MalformedCatalogField.Version + certificate == null -> MalformedCatalogField.Certificate + download == null -> MalformedCatalogField.Download + signature == null -> MalformedCatalogField.Signature + else -> MalformedCatalogField.SignatureDigest + }, + ) + return@mapNotNull null + } AppStoreRelease( appId = appId, version = version, @@ -66,6 +116,7 @@ internal fun parseReleases( ) } .toList() + return CatalogReleases(parsed, malformed.toList()) } private data class ParsedVersion( @@ -107,22 +158,24 @@ internal fun compatibleReleaseCandidates( releases: List, installedVersion: String?, ): List { - if (installedVersion == null) { - return releases - .filterNot { release -> release.version.isRecognizedPrerelease() } - .sortedWith { left, right -> compareSemanticVersions(right.version, left.version) } + val compatible = releases.filter { release -> isCompatibleReleaseVersion(release.version, installedVersion) } + return if (installedVersion == null) { + compatible.sortedWith { left, right -> compareSemanticVersions(right.version, left.version) } + } else { + compatible.sortedWith { left, right -> compareCompatibility(left.version, right.version, installedVersion) } } - val installed = parseVersion(installedVersion) ?: return emptyList() - if (installed.numeric.size < 2 || installedVersion.isRecognizedPrerelease()) return emptyList() - return releases - .filterNot { release -> release.version.isRecognizedPrerelease() } - .filter { release -> - val candidate = parseVersion(release.version) - candidate != null && candidate.numeric.size >= 2 && - candidate.numeric[0] == installed.numeric[0] && - candidate.numeric[1] == installed.numeric[1] - } - .sortedWith { left, right -> compareCompatibility(left.version, right.version, installedVersion) } +} + +/** A non-prerelease with the installed major and minor version, or any non-prerelease when unknown. */ +private fun isCompatibleReleaseVersion(version: String, installedVersion: String?): Boolean { + if (version.isRecognizedPrerelease()) return false + if (installedVersion == null) return true + val installed = parseVersion(installedVersion) ?: return false + if (installed.numeric.size < 2 || installedVersion.isRecognizedPrerelease()) return false + val candidate = parseVersion(version) ?: return false + return candidate.numeric.size >= 2 && + candidate.numeric[0] == installed.numeric[0] && + candidate.numeric[1] == installed.numeric[1] } private fun compareCompatibility(left: String, right: String, installed: String): Int { diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt index 514b6dc48..eee362d80 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -13,6 +13,12 @@ enum class ContractUnavailableReason { /** Matching releases were inspected and none provides a usable contract or safe read routes. */ NoUsableContract, + + /** + * The catalog lists the app, but an entry that would have been selected lacks a required value + * such as its certificate, download, signature, or signature digest, so it cannot be verified. + */ + MalformedSource, } /** The typed result of [SignedAppStoreContractAcquirer.acquireOutcome]. Failures are thrown instead. */ diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index c462575ea..e720923df 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -88,23 +88,22 @@ class SignedAppStoreContractAcquirer( } val compatibleCatalogUrl = "${appStoreBaseUrl.trimEnd('/')}/platform/$coreServerVersion/apps.json" - val platformReleases = fetchCatalogReleases(compatibleCatalogUrl, request.appId) + val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId) + val platformReleases = platformCatalog.releases val exactPlatformRelease = request.installedAppVersion?.let { installed -> platformReleases.firstOrNull { release -> release.version == installed } } - val exactRelease = if (request.installedAppVersion != null && exactPlatformRelease == null) { - val allReleasesUrl = "${appStoreBaseUrl.trimEnd('/')}/apps.json" - fetchCatalogReleases(allReleasesUrl, request.appId) - .firstOrNull { release -> release.version == request.installedAppVersion } - } else { - exactPlatformRelease + val allCatalog = request.installedAppVersion?.takeIf { exactPlatformRelease == null }?.let { + fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId) } + val exactRelease = exactPlatformRelease + ?: allCatalog?.releases?.firstOrNull { release -> release.version == request.installedAppVersion } val compatibleReleases = compatibleReleaseCandidates( releases = platformReleases, installedVersion = request.installedAppVersion, ).filterNot { candidate -> candidate.version == exactRelease?.version } val releases = listOfNotNull(exactRelease) + compatibleReleases - if (releases.isEmpty()) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoMatchingRelease) + if (releases.isEmpty()) return unmatchedReleaseOutcome(request.installedAppVersion, platformCatalog, allCatalog) val attempt = ContractAcquisitionAttempt() val missingPackageContracts = mutableListOf() @@ -424,7 +423,7 @@ class SignedAppStoreContractAcquirer( private fun fetchCatalogReleases( catalogUrl: String, appId: String, - ): List { + ): CatalogReleases { requireSecureUrl(catalogUrl) catalogCache.load(catalogUrl)?.let { cached -> return parseReleases( diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt new file mode 100644 index 000000000..c192bd432 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt @@ -0,0 +1,134 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +class MalformedCatalogReleaseTest { + @Test + fun `parsing records which required value made each entry unusable`() { + val catalog = catalog( + release("4.0.1", omit = "download"), + release("4.0.2"), + release(null), + release("4.0.3", omit = "signature"), + release("4.0.4", omit = "signatureDigest"), + ) + val parsed = parseReleases(catalog.toString(), "cospend", "https://fixture.invalid/apps.json", false) + assertEquals(listOf("4.0.2"), parsed.releases.map(AppStoreRelease::version)) + assertEquals( + listOf( + MalformedCatalogEntry("4.0.1", MalformedCatalogField.Download), + MalformedCatalogEntry(null, MalformedCatalogField.Version), + MalformedCatalogEntry("4.0.3", MalformedCatalogField.Signature), + MalformedCatalogEntry("4.0.4", MalformedCatalogField.SignatureDigest), + ), + parsed.malformed, + ) + val withoutCertificate = catalog(release("4.0.2"), certificate = null) + assertEquals( + listOf(MalformedCatalogEntry("4.0.2", MalformedCatalogField.Certificate)), + parseReleases(withoutCertificate.toString(), "cospend", "https://fixture.invalid/apps.json", false).malformed, + ) + } + + @Test + fun `a matching release missing any required value is a malformed source`() { + for (field in listOf("download", "signature", "signatureDigest", "version", "certificate")) { + val catalog = if (field == "certificate") { + catalog(release("4.0.2"), certificate = null) + } else { + catalog(release(if (field == "version") null else "4.0.2", omit = field)) + } + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquireFrom(catalog), field) + } + } + + @Test + fun `an app without a releases list is a malformed source`() { + val catalog = JSONArray().put(JSONObject().put("id", "cospend").put("certificate", "certificate")) + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquireFrom(catalog)) + } + + @Test + fun `a compatible malformed release is a malformed source when nothing usable matches`() { + val catalog = catalog(release("4.0.1", omit = "signature")) + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquireFrom(catalog)) + } + + @Test + fun `malformed entries that would not be selected leave genuine absence unchanged`() { + for (catalog in listOf( + catalog(release("5.1.0", omit = "signature")), + catalog(release("4.0.3-beta.1", omit = "download")), + JSONArray().put(JSONObject().put("id", "other").put("releases", JSONArray())), + )) { + assertEquals(unavailable(ContractUnavailableReason.NoMatchingRelease), acquireFrom(catalog)) + } + } + + @Test + fun `a malformed non-matching entry beside a valid matching release is ignored`() { + MockWebServer().use { server -> + server.start() + val catalog = catalog(release("5.1.0", omit = "signature"), release("4.0.2", server = server)) + server.enqueue(MockResponse(body = catalog.toString())) + server.enqueue(MockResponse(body = "package")) + val outcome = acquirer(server) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }.acquireOutcome(request()) + val acquired = assertIs(outcome) + assertEquals("4.0.2", acquired.contract.contractVersion) + assertEquals(2, server.requestCount) + } + } + + private fun acquireFrom(catalog: JSONArray): ContractAcquisitionOutcome = MockWebServer().use { server -> + server.start() + // The installed version is absent from the platform catalog, so the full catalog is read too. + repeat(2) { server.enqueue(MockResponse(body = catalog.toString())) } + acquirer(server) { error("No package may be verified.") }.acquireOutcome(request()) + } + + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun request() = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun catalog(vararg releases: JSONObject, certificate: String? = "certificate"): JSONArray { + val app = JSONObject().put("id", "cospend").put("releases", JSONArray(releases.toList())) + certificate?.let { app.put("certificate", it) } + return JSONArray().put(app) + } + + private fun release(version: String?, omit: String? = null, server: MockWebServer? = null): JSONObject { + val download = server?.url("downloads/cospend-$version.tar.gz")?.toString() + ?: "https://fixture.invalid/cospend-$version.tar.gz" + val values = mapOf( + "version" to version, + "download" to download, + "signature" to "c2lnbmF0dXJl", + "signatureDigest" to "sha512", + ) + return JSONObject().also { release -> + values.forEach { (key, value) -> if (key != omit && value != null) release.put(key, value) } + release.put("isNightly", false) + } + } + + private fun acquirer( + server: MockWebServer, + verify: (AppStoreRelease) -> VerifiedPackageContract, + ) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + ) +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 5001f8d4b..3fad60042 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|810 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|809 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 1dec56aaf..0c879d4bd 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -33,6 +33,9 @@ enum class AppStoreContractUnavailableReason { /** Matching releases were inspected and none provides a usable contract or safe read routes. */ NoUsableContract, + + /** The catalog lists the app, but the entry that would be selected lacks data needed to verify it. */ + MalformedSource, } /** Result of [NextcloudPlatformServices.acquireSignedOpenApiContract]. Failures are thrown instead. */ @@ -60,6 +63,7 @@ enum class DynamicContractFallbackReason { NoVerifiedContract, NoMatchingRelease, SourceTemporarilyUnavailable, + MalformedSource, VersionUnavailable, AppStoreUnreachable, AppStoreUnavailable, @@ -83,6 +87,7 @@ internal fun AppStoreContractUnavailableReason.fallbackReason(): DynamicContract AppStoreContractUnavailableReason.SourceTemporarilyUnavailable -> DynamicContractFallbackReason.SourceTemporarilyUnavailable AppStoreContractUnavailableReason.NoUsableContract -> DynamicContractFallbackReason.NoVerifiedContract + AppStoreContractUnavailableReason.MalformedSource -> DynamicContractFallbackReason.MalformedSource } /** Classifies a failure from the platform acquisition boundary without reading exception text. */ @@ -139,6 +144,12 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "yet. Try again later.", "Try again", ) + DynamicContractFallbackReason.MalformedSource -> DynamicContractFallbackNotice( + "App Store entry incomplete", + "The Nextcloud App Store entry for this app version is missing data needed to verify its signed " + + "release, so no native view is offered. Only app details are shown.", + "Check again", + ) DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( "App version not confirmed", "The server or app version could not be confirmed, so no matching signed App Store release " + diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index 9c0f36811..1e1746a28 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -53,6 +53,7 @@ class DynamicContractFallbackDiscoveryTest { AppStoreContractUnavailableReason.SourceTemporarilyUnavailable to DynamicContractFallbackReason.SourceTemporarilyUnavailable, AppStoreContractUnavailableReason.NoUsableContract to DynamicContractFallbackReason.NoVerifiedContract, + AppStoreContractUnavailableReason.MalformedSource to DynamicContractFallbackReason.MalformedSource, ) assertEquals(AppStoreContractUnavailableReason.entries.toSet(), expected.keys) for ((unavailable, reason) in expected) { @@ -65,6 +66,8 @@ class DynamicContractFallbackDiscoveryTest { val throttled = DynamicContractFallbackReason.SourceTemporarilyUnavailable.fallbackNotice() assertEquals("Try again", throttled.actionLabel) assertEquals("Check again", DynamicContractFallbackReason.NoMatchingRelease.fallbackNotice().actionLabel) + val malformed = DynamicContractFallbackReason.MalformedSource.fallbackNotice() + assertFalse("lists no" in malformed.message, malformed.message) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index 9f8734a42..9d8f2a2da 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -52,6 +52,7 @@ class JvmAppStoreContractAcquisitionTest { ContractUnavailableReason.SourceTemporarilyUnavailable to AppStoreContractUnavailableReason.SourceTemporarilyUnavailable, ContractUnavailableReason.NoUsableContract to AppStoreContractUnavailableReason.NoUsableContract, + ContractUnavailableReason.MalformedSource to AppStoreContractUnavailableReason.MalformedSource, )) { assertEquals( AppStoreContractAcquisition.Unavailable(expected), diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index e6c178632..d7153e52b 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -42,6 +42,7 @@ private fun ContractUnavailableReason.toAppStoreReason(): AppStoreContractUnavai ContractUnavailableReason.SourceTemporarilyUnavailable -> AppStoreContractUnavailableReason.SourceTemporarilyUnavailable ContractUnavailableReason.NoUsableContract -> AppStoreContractUnavailableReason.NoUsableContract + ContractUnavailableReason.MalformedSource -> AppStoreContractUnavailableReason.MalformedSource } private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFailureKind = when (this) { From 5c42d4bf33057b1b6841215640a5fa542d790e12 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 01:23:10 +0200 Subject: [PATCH 14/49] fix: keep inconclusive server probes in dynamic contract fallback reasons The same-origin OCS catalog, spec, and static contract probes now record the first inconclusive result as a typed failure: no response, a server error, or rate limiting. A definitive absence records nothing. When the App Store then completes without a contract, an inconclusive server probe takes precedence and the card offers a retry instead of claiming that neither source provides an API. Diagnostics keep both sources. --- DYNAMIC_APP_DESCRIPTOR.md | 10 ++ .../app/DynamicContractFallback.kt | 19 ++- .../app/DynamicContractInfo.kt | 1 + .../app/DynamicSameOriginDiscovery.kt | 44 +++++- .../app/DynamicServerProbeFallbackTest.kt | 144 ++++++++++++++++++ 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index ae94fdfe4..2a0277d38 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -191,6 +191,7 @@ The fallback card derives its title, explanation, and action from that reason: digest; - the app's linked source refused or rate limited the last fallback request (HTTP 403 or 429), so a contract may still exist; +- the server's own API catalog or contract probe was inconclusive; - the server or app version could not be confirmed; - the App Store could not be reached, or did not provide the release; - the package or its source failed verification; @@ -219,6 +220,15 @@ parsing records which required value made each dropped entry unusable. A dropped entry counts only when it would have been selected: the exact installed version, a patch-compatible release, or an entry whose version cannot be read. +[`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) +records the first inconclusive same-origin probe as a typed failure: no +response, a 5xx status or unreadable success body, or HTTP 429. A 404, another +definitive status, or an empty or non-advertising OCS API Viewer catalog is +absence and records nothing. When the App Store then completes without a +contract, an inconclusive server probe takes precedence over every App Store +no-contract reason and the card offers a retry. A definitive server absence +keeps the App Store reason. Diagnostics record both sources either way. + ## Read failures [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 0c879d4bd..ee1c2b541 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -64,6 +64,9 @@ enum class DynamicContractFallbackReason { NoMatchingRelease, SourceTemporarilyUnavailable, MalformedSource, + + /** The same-origin server probe was inconclusive and the App Store yielded no contract. */ + ServerProbeInconclusive, VersionUnavailable, AppStoreUnreachable, AppStoreUnavailable, @@ -100,13 +103,19 @@ internal fun DynamicDescriptorDiscovery.withFallback( diagnostic: String, ): DynamicDescriptorDiscovery = copy(diagnostics = diagnostics + diagnostic, fallbackReason = reason) -/** Keeps the metadata fallback when the App Store boundary completed without a contract. */ +/** + * Keeps the metadata fallback when the App Store boundary completed without a contract. + * + * Precedence: an inconclusive same-origin probe wins over every App Store no-contract reason, + * because the server may still offer a contract and checking again can help. A definitive server + * absence leaves the App Store reason in charge. Diagnostics keep both sources. + */ internal fun DynamicDescriptorDiscovery.withUnavailable( reason: AppStoreContractUnavailableReason, appId: String, coreVersion: String, ): DynamicDescriptorDiscovery = withFallback( - reason.fallbackReason(), + fallbackReason.takeIf { it == DynamicContractFallbackReason.ServerProbeInconclusive } ?: reason.fallbackReason(), "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", ) @@ -150,6 +159,12 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "release, so no native view is offered. Only app details are shown.", "Check again", ) + DynamicContractFallbackReason.ServerProbeInconclusive -> DynamicContractFallbackNotice( + "Server not checked", + "The server's API catalog did not respond, returned an error, or limited requests, and the App " + + "Store offered no verified contract. Check the connection and try again.", + "Try again", + ) DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( "App version not confirmed", "The server or app version could not be confirmed, so no matching signed App Store release " + diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt index fbd1d557b..74d98ffa0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt @@ -86,6 +86,7 @@ private fun String.safeDiagnosticCode(): String? = when { "api-viewer-unavailable" contains("static OpenAPI", ignoreCase = true) -> "static-contract-unavailable" contains("App Store contract acquisition failed", ignoreCase = true) -> "app-store-acquisition-failed" + contains("server API probe was inconclusive", ignoreCase = true) -> "server-probe-inconclusive" contains("derived", ignoreCase = true) && contains("read-only endpoints", ignoreCase = true) -> "verified-static-read-routes" contains("verified read", ignoreCase = true) && contains("fallback routes", ignoreCase = true) -> diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index 4d8b39a9b..d1c20ca91 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -12,6 +12,35 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull +/** Why a same-origin catalog or contract probe could not prove that the server offers no contract. */ +enum class DynamicServerProbeFailure { + /** The request produced no HTTP response. */ + NoResponse, + + /** The server answered with a 5xx status or an unreadable success body. */ + ServerError, + + /** The server rate limited the request (HTTP 429). */ + Throttled, +} + +/** Keeps the first inconclusive probe; a definitive answer such as 404 never records anything. */ +private class DynamicServerProbe { + var failure: DynamicServerProbeFailure? = null + private set + + fun record(failure: DynamicServerProbeFailure) { + if (this.failure == null) this.failure = failure + } + + fun record(response: NextcloudApiResponse) { + when { + response.status == 429 -> record(DynamicServerProbeFailure.Throttled) + response.status in 500..599 -> record(DynamicServerProbeFailure.ServerError) + } + } +} + internal suspend fun discoverDynamicAppDescriptor( serverUrl: String, app: NextcloudAppEntry, @@ -31,6 +60,7 @@ internal suspend fun discoverDynamicAppDescriptor( val identity = AppIdentity(app.id, app.name, "server-installed") val compiler = DynamicAppDescriptorCompiler() val diagnostics = mutableListOf() + val probe = DynamicServerProbe() onProgress( DynamicDescriptorDiscoveryProgress( @@ -38,7 +68,7 @@ internal suspend fun discoverDynamicAppDescriptor( "Checking the server API catalog", ), ) - val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics) + val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics, probe) viewerSpecs.forEach { specId -> onProgress( DynamicDescriptorDiscoveryProgress( @@ -49,15 +79,18 @@ internal suspend fun discoverDynamicAppDescriptor( val path = "$OCS_API_VIEWER_SPEC_PATH/${specId.encodeUrlComponent()}" val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(path)) } .onFailure { failure -> + probe.record(DynamicServerProbeFailure.NoResponse) diagnostics += "OCS API Viewer could not load $specId: ${failure.message ?: "request failed"}." } .getOrNull() ?: return@forEach if (response.status !in 200..299) { + probe.record(response) diagnostics += "OCS API Viewer could not load $specId (HTTP ${response.status})." return@forEach } val document = response.parseOpenApiDocument() if (document == null) { + probe.record(DynamicServerProbeFailure.ServerError) diagnostics += "OCS API Viewer returned an invalid OpenAPI JSON body for $specId${response.contentTypeDiagnostic()}." return@forEach } @@ -96,8 +129,9 @@ internal suspend fun discoverDynamicAppDescriptor( staticCandidates.forEach { path -> val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(path)) - }.getOrNull() ?: return@forEach + }.onFailure { probe.record(DynamicServerProbeFailure.NoResponse) }.getOrNull() ?: return@forEach if (response.status !in 200..299 || response.contentType?.contains("json", ignoreCase = true) != true) { + probe.record(response) return@forEach } val document = response.parseOpenApiDocument() ?: return@forEach @@ -119,6 +153,7 @@ internal suspend fun discoverDynamicAppDescriptor( } diagnostics += "No valid static OpenAPI document was found in the app's advertised asset locations." + probe.failure?.let { failure -> diagnostics += "The server API probe was inconclusive: ${failure.name}." } onProgress( DynamicDescriptorDiscoveryProgress( @@ -136,6 +171,7 @@ internal suspend fun discoverDynamicAppDescriptor( sourcePath = null, acquisition = DynamicDescriptorAcquisition.MetadataFallback, diagnostics = diagnostics, + fallbackReason = probe.failure?.let { DynamicContractFallbackReason.ServerProbeInconclusive }, ) } @@ -143,9 +179,11 @@ private suspend fun discoverOfficialViewerSpecIds( appId: String, execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, diagnostics: MutableList, + probe: DynamicServerProbe, ): List { val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(OCS_API_VIEWER_CATALOG_PATH)) } .onFailure { failure -> + probe.record(DynamicServerProbeFailure.NoResponse) diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint: " + (failure.message ?: "request failed") + "." } @@ -155,12 +193,14 @@ private suspend fun discoverOfficialViewerSpecIds( return emptyList() } if (response.status !in 200..299) { + probe.record(response) diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint (HTTP ${response.status})." return emptyList() } val catalog = runCatchingPreservingCancellation { dynamicJson.parseToJsonElement(response.body.decodeToString()) as? JsonArray } .getOrNull() if (catalog == null) { + probe.record(DynamicServerProbeFailure.ServerError) diagnostics += "OCS API Viewer returned an invalid app catalog${response.contentTypeDiagnostic()}." return emptyList() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt new file mode 100644 index 000000000..cc1674637 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -0,0 +1,144 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DynamicServerProbeFallbackTest { + private val session = NextcloudSession("https://fixture.invalid", "fixture", "synthetic") + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + private enum class ServerProbe(val failure: DynamicServerProbeFailure?) { + Absent(null), + EmptyCatalog(null), + Network(DynamicServerProbeFailure.NoResponse), + ServerError(DynamicServerProbeFailure.ServerError), + Throttled(DynamicServerProbeFailure.Throttled), + } + + @Test + fun everyServerProbeAndAppStoreOutcomeCombinationHasTheDocumentedReason() = runBlocking { + for (probe in ServerProbe.entries) { + for (unavailable in AppStoreContractUnavailableReason.entries) { + val discovery = discover(probe) { AppStoreContractAcquisition.Unavailable(unavailable) } + val expected = if (probe.failure == null) { + unavailable.fallbackReason() + } else { + DynamicContractFallbackReason.ServerProbeInconclusive + } + val label = "$probe x $unavailable" + assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition, label) + assertEquals(expected, discovery.fallbackReason, label) + // Diagnostics keep both sources regardless of which one decided the copy. + assertTrue(discovery.diagnostics.any { unavailable.name in it && "No App Store contract" in it }, label) + val serverDiagnostics = discovery.diagnostics.filter { "probe was inconclusive" in it } + assertEquals(listOfNotNull(probe.failure?.let { "The server API probe was inconclusive: ${it.name}." }), + serverDiagnostics, label) + val codes = discovery.toContractInfo(null).diagnosticCodes + assertEquals(probe.failure != null, "server-probe-inconclusive" in codes, label) + } + } + } + + @Test + fun definitiveAbsenceKeepsThePermanentCopyAndAnInconclusiveProbeIsRetryable() = runBlocking { + val absent = discover(ServerProbe.Absent) { + AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) + } + assertEquals(DynamicContractFallbackReason.NoVerifiedContract, absent.fallbackReason) + val inconclusive = DynamicContractFallbackReason.ServerProbeInconclusive.fallbackNotice() + assertEquals("Try again", inconclusive.actionLabel) + assertTrue("Neither" !in inconclusive.message) + } + + @Test + fun oneInconclusiveStaticProbeAmongDefinitiveAnswersStillMakesTheServerInconclusive() = runBlocking { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> + val status = if (request.relativePath == "/custom_apps/example/openapi.json") 503 else 404 + NextcloudApiResponse(status, ByteArray(0), "application/json", etag = null) + }) + assertEquals(DynamicContractFallbackReason.ServerProbeInconclusive, discovery.fallbackReason) + assertTrue("The server API probe was inconclusive: ServerError." in discovery.diagnostics) + } + + @Test + fun theFirstInconclusiveProbeIsRecordedAndDefinitiveProbesRecordNothing() = runBlocking { + for (probe in ServerProbe.entries) { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = server(probe)) + val expected = probe.failure?.let { DynamicContractFallbackReason.ServerProbeInconclusive } + assertEquals(expected, discovery.fallbackReason, probe.name) + } + val catalogThrottledThenFailing = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> + if (request.relativePath.contains("ocs_api_viewer")) { + NextcloudApiResponse(429, ByteArray(0), "application/json", etag = null) + } else { + throw IllegalStateException("synthetic transport failure") + } + }) + assertEquals( + listOf("The server API probe was inconclusive: Throttled."), + catalogThrottledThenFailing.diagnostics.filter { "probe was inconclusive" in it }, + ) + } + + @Test + fun cancellationDuringAServerProbeIsNotRecordedAsInconclusive() = runBlocking { + val cancelled = CancellationException("stop") + assertSame(cancelled, assertFailsWith { + discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { throw cancelled }) + }) + } + + private fun server(probe: ServerProbe): suspend (NextcloudApiRequest) -> NextcloudApiResponse = { request -> + when (probe) { + ServerProbe.Absent -> NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) + ServerProbe.EmptyCatalog -> if (request.relativePath.contains("ocs_api_viewer")) { + NextcloudApiResponse(200, "[]".encodeToByteArray(), "application/json", etag = null) + } else { + NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) + } + ServerProbe.Network -> throw IllegalStateException("synthetic transport failure") + ServerProbe.ServerError -> NextcloudApiResponse(503, ByteArray(0), "application/json", etag = null) + ServerProbe.Throttled -> NextcloudApiResponse(429, ByteArray(0), "application/json", etag = null) + } + } + + private suspend fun discover( + probe: ServerProbe, + acquire: suspend () -> AppStoreContractAcquisition, + ): DynamicDescriptorDiscovery = discoverDynamicAppDescriptor( + services = services(server(probe), acquire), + session = session, + app = app, + serverVersion = "34.0.3", + installedAppVersionHint = "1.0.0", + ) + + /** Only same-origin probes and the App Store boundary are available. */ + private fun services( + execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, + acquire: suspend () -> AppStoreContractAcquisition, + ): NextcloudPlatformServices { + val unavailable = Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { _, method, _ -> error("Unexpected ${method.name}") } as NextcloudPlatformServices + return object : NextcloudPlatformServices by unavailable { + override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = Unit + + override suspend fun executeNextcloudApi(session: NextcloudSession, request: NextcloudApiRequest) = + execute(request) + + override suspend fun acquireSignedOpenApiContract( + appId: String, + serverVersion: String, + installedAppVersion: String?, + ): AppStoreContractAcquisition = acquire() + } + } +} From 271588fc2acbd2f0879c1f49f7fb0e851a1fbc97 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:46:35 +0000 Subject: [PATCH 15/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 302297885..9c7e4096d 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -95,6 +95,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", @@ -584,8 +585,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "6862bd7d210866f1f45e70c910159ff83029cde3428c59ad554cbb9dd8123603", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "5cb677f5ebe7dfef4118a9221bba9cfdbb6f7416a0e6bccc6405bdf22bcc4b77", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "ab8c2bb9a6ef6f5e194536740f638923b257db509b16f3ee860ece13fc8a1a57", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationCache.kt": "af27d14641fde05a5c1d1688676102c6630ccce4cd8d06ab0e3d58c95432aa8f", @@ -595,10 +596,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "3942c5349f3c0d1e49266defe22332d219766320c47d38e4206019717ff6edd4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "f3167838346590009337e2d20097515c400b8939e9fa01176eb7391735a2f315", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "d2aff4e95e80ee54e81be01a9bc055728d87a3fde9981c82ee30b269b04461da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", From c866f6a1bd71041e3d38394ce7d11dec35eaa016 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 02:11:24 +0200 Subject: [PATCH 16/49] fix(contracts): classify unreadable catalogs and linked archives as source data A catalog response that is truncated, empty, not JSON, or not an array now yields a malformed-source outcome instead of a parser exception that the boundary reported as an unexpected client fault, and such a document is not cached. A corrupt or mismatched App Store-linked source archive yields no candidate instead of escaping as an unexpected failure. --- DYNAMIC_APP_DESCRIPTOR.md | 4 + .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 2 +- .../contracts/AppStoreCatalogReleases.kt | 25 +++- .../contracts/LinkedSourceArchive.kt | 27 ++++ .../SignedAppStoreContractAcquirer.kt | 16 +-- .../contracts/UnreadableContractSourceTest.kt | 118 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- 7 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 2a0277d38..7ec921652 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -219,6 +219,10 @@ and the shared JVM boundary carries that reason to the fallback card. Catalog parsing records which required value made each dropped entry unusable. A dropped entry counts only when it would have been selected: the exact installed version, a patch-compatible release, or an entry whose version cannot be read. +A catalog document that is truncated, empty, not JSON, or not an array is also +a malformed source; it is never cached, so checking again downloads it again. +A corrupt or mismatched App Store-linked source archive yields no candidate, +like an archive without a contract, instead of an unexpected client fault. [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records the first inconclusive same-origin probe as a typed failure: no diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 58f475898..1feb159f9 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -56,7 +56,7 @@ class AppOwnedOpenApiAndroidInstrumentedTest { // unit test ContractModuleInitializationTest fails when a facade is missing from this list. listOf( "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "AppStoreCatalogReleasesKt", - "ContractAcquisitionFailureKt", + "ContractAcquisitionFailureKt", "LinkedSourceArchiveKt", "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 7fd90d2a7..5b04dffdb 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -2,10 +2,21 @@ package dev.obiente.nextcloudnative.contracts import java.net.URI import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject /** Which required catalog value made an entry for the requested app unusable. */ -internal enum class MalformedCatalogField { Releases, Entry, Version, Certificate, Download, Signature, SignatureDigest } +internal enum class MalformedCatalogField { + /** The catalog document itself is not a readable JSON array. */ + Document, + Releases, + Entry, + Version, + Certificate, + Download, + Signature, + SignatureDigest, +} /** A catalog entry for the requested app that parsing dropped. A null version could not be read. */ internal data class MalformedCatalogEntry(val version: String?, val field: MalformedCatalogField) @@ -14,7 +25,10 @@ internal data class MalformedCatalogEntry(val version: String?, val field: Malfo internal data class CatalogReleases( val releases: List, val malformed: List = emptyList(), -) +) { + /** False when the whole document was unreadable; such a document must not be cached. */ + val documentReadable: Boolean get() = malformed.none { entry -> entry.field == MalformedCatalogField.Document } +} /** * Explains an empty release selection. A dropped entry that would have been selected, or one whose @@ -62,7 +76,12 @@ internal fun parseReleases( catalogUrl: String, requireOfficialGitHubHost: Boolean, ): CatalogReleases { - val apps = JSONArray(catalogJson) + // A truncated, empty, non-JSON, or non-array document is malformed source data, not a client fault. + val apps = try { + JSONArray(catalogJson) + } catch (_: JSONException) { + return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, MalformedCatalogField.Document))) + } val app = (0 until apps.length()) .asSequence() .mapNotNull(apps::optJSONObject) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt new file mode 100644 index 000000000..969336d62 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt @@ -0,0 +1,27 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.IOException +import java.util.concurrent.CancellationException + +/** + * Reads a contract from an App Store-linked source archive, which is a lower-trust fallback. + * + * A truncated or corrupt archive, an unsafe entry path, or an archive whose app identity does not + * match the release is unusable source data. It yields no candidate, exactly like an archive + * without a contract, instead of escaping as an unexpected client failure. Cancellation propagates. + */ +internal fun extractLinkedSourceContractOrNull( + archive: ByteArray, + release: AppStoreRelease, +): VerifiedPackageContract? = try { + extractOpenApiContract(archive, release) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: IllegalStateException) { + // Includes a missing contract, identity mismatches, and malformed archive structure. + null +} catch (_: IllegalArgumentException) { + null +} catch (_: IOException) { + null +} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index e720923df..632f00d67 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -274,11 +274,7 @@ class SignedAppStoreContractAcquirer( // The archive is the last exact-tag fallback; a refusal here leaves the outcome unknown. onRecoverableStatus = attempt::markSourceTemporarilyUnavailable, ) ?: return null - val extracted = try { - extractOpenApiContract(archive, release) - } catch (_: OpenApiContractMissingException) { - return null - } + val extracted = extractLinkedSourceContractOrNull(archive, release) ?: return null return OpenApiCandidate( path = extracted.specFile, document = extracted.document, @@ -441,13 +437,9 @@ class SignedAppStoreContractAcquirer( requireSecureResponse(catalogResponse) catalogResponse.readBodyLimited(MAX_CATALOG_BYTES) } - catalogCache.storeBestEffort(catalogUrl, bytes) - return parseReleases( - bytes.decodeToString(), - appId, - catalogUrl, - requireOfficialGitHubHost = requireHttps, - ) + // An unreadable document is not cached, so checking again downloads the catalog again. + return parseReleases(bytes.decodeToString(), appId, catalogUrl, requireOfficialGitHubHost = requireHttps) + .also { parsed -> if (parsed.documentReadable) catalogCache.storeBestEffort(catalogUrl, bytes) } } private fun execute(request: Request): Response = httpClient.newCall(request).execute() diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt new file mode 100644 index 000000000..7356abf5b --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt @@ -0,0 +1,118 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class UnreadableContractSourceTest { + @Test + fun `an unreadable catalog document is a malformed source and is never cached`() { + for (body in listOf( + """[{"id":"cospend","certificate":"c","releases":[{"version":"4.0.2"""", + """{"id":"cospend","releases":[]}""", + "", + "Maintenance", + )) { + MockWebServer().use { server -> + server.start() + // The platform catalog has no usable exact release, so the full catalog is read too. + repeat(2) { server.enqueue(MockResponse(body = body)) } + val cache = RecordingCatalogCache() + val outcome = acquirer(server, cache).acquireOutcome(request()) + assertEquals(ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.MalformedSource), outcome, body) + assertEquals(emptyList(), cache.stored, body) + assertEquals(2, server.requestCount, body) + } + } + } + + @Test + fun `parsing reports an unreadable document without throwing`() { + for (body in listOf("[", "{}", "", "not json", "42")) { + val parsed = parseReleases(body, "cospend", "https://fixture.invalid/apps.json", false) + assertEquals(listOf(MalformedCatalogEntry(null, MalformedCatalogField.Document)), parsed.malformed, body) + assertTrue(!parsed.documentReadable, body) + } + val readable = parseReleases("[]", "cospend", "https://fixture.invalid/apps.json", false) + assertTrue(readable.documentReadable) + } + + @Test + fun `an unreadable platform catalog still allows an exact release from the full catalog`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = "[truncated")) + server.enqueue(MockResponse(body = validCatalog(server))) + server.enqueue(MockResponse(body = "package")) + val cache = RecordingCatalogCache() + val outcome = acquirer(server, cache) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }.acquireOutcome(request()) + assertIs(outcome) + assertEquals(listOf(server.url("api/v1/apps.json").toString()), cache.stored) + } + } + + @Test + fun `a corrupt linked source archive is an unusable source rather than a client fault`() { + for (archive in listOf("not a gzip archive", "")) { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = validCatalog(server, linked = true))) + server.enqueue(MockResponse(body = "signed-package-without-openapi")) + server.enqueue(MockResponse(body = "cospend4.0.2")) + server.enqueue(MockResponse(code = 403, body = "rate limited")) + server.enqueue(MockResponse(body = archive)) + val outcome = acquirer(server) { + throw OpenApiContractMissingException("synthetic package without a contract") + }.acquireOutcome(request()) + assertEquals(ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoUsableContract), outcome) + assertEquals(5, server.requestCount) + } + } + } + + private class RecordingCatalogCache : AppStoreCatalogCache { + val stored = mutableListOf() + + override fun load(url: String): ByteArray? = null + + override fun store(url: String, bytes: ByteArray) { + stored += url + } + } + + private fun request() = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun validCatalog(server: MockWebServer, linked: Boolean = false): String { + val download = if (linked) { + server.url("source-owner/source-repo/releases/download/v4.0.2/cospend-4.0.2.tar.gz") + } else { + server.url("downloads/cospend-4.0.2.tar.gz") + } + return """[{"id":"cospend","website":"https://github.com/source-owner/source-repo",""" + + """"certificate":"certificate","releases":[{"version":"4.0.2","download":"$download",""" + + """"signature":"c2lnbmF0dXJl","signatureDigest":"sha512","isNightly":false}]}]""" + } + + private fun acquirer( + server: MockWebServer, + cache: AppStoreCatalogCache = MemoryAppStoreCatalogCache(), + verify: (AppStoreRelease) -> VerifiedPackageContract = { error("No package may be verified.") }, + ) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + catalogCache = cache, + rawGitHubBaseUrl = server.url("raw").toString(), + githubApiBaseUrl = server.url("github-api").toString(), + codeloadGitHubBaseUrl = server.url("codeload").toString(), + ) +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 3fad60042..3096d3d6d 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|809 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|801 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 From a6efb9f18f4b944a3e55447b84bdb5c5f2c65379 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 02:18:45 +0200 Subject: [PATCH 17/49] fix: classify unreadable server contracts and access denials in probe results A JSON response at a static or OCS API Viewer contract location that is not readable, or declares no OpenAPI version, is now an inconclusive probe result, and a Swagger 2 or uncompilable contract is an unsupported contract instead of being skipped silently. HTTP 401 and 403 from the catalog, spec, or static probes are recorded as authentication and permission results with their own fallback copy. When probes disagree, access problems come first, then inconclusive results, then an unsupported contract. --- DYNAMIC_APP_DESCRIPTOR.md | 34 +++- .../app/DynamicContractFallback.kt | 37 ++++- .../app/DynamicContractInfo.kt | 2 + .../app/DynamicSameOriginDiscovery.kt | 82 ++++++++-- .../app/DynamicServerProbeFallbackTest.kt | 152 ++++++++++++------ 5 files changed, 233 insertions(+), 74 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 7ec921652..fe7a5eb9e 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -192,10 +192,13 @@ The fallback card derives its title, explanation, and action from that reason: - the app's linked source refused or rate limited the last fallback request (HTTP 403 or 429), so a contract may still exist; - the server's own API catalog or contract probe was inconclusive; +- the server rejected the account's credentials, or the account may not read + the server's API catalog, during that probe; +- the server or App Store offered a contract that cannot be shown natively, + such as a Swagger 2 document; - the server or app version could not be confirmed; - the App Store could not be reached, or did not provide the release; - the package or its source failed verification; -- the verified contract cannot be shown natively yet; - the client could not complete the check. [`ContractAcquisitionFailure.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt) @@ -225,13 +228,28 @@ A corrupt or mismatched App Store-linked source archive yields no candidate, like an archive without a contract, instead of an unexpected client fault. [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) -records the first inconclusive same-origin probe as a typed failure: no -response, a 5xx status or unreadable success body, or HTTP 429. A 404, another -definitive status, or an empty or non-advertising OCS API Viewer catalog is -absence and records nothing. When the App Store then completes without a -contract, an inconclusive server probe takes precedence over every App Store -no-contract reason and the card offers a retry. A definitive server absence -keeps the App Store reason. Diagnostics record both sources either way. +records same-origin probe results as typed `DynamicServerProbeFailure` values. +When probes disagree, the highest-precedence result wins, and the first one +wins among equals: + +1. HTTP 401 (`AuthenticationRequired`): the card asks the person to sign in + again. +2. HTTP 403 (`PermissionDenied`): the account may not read the catalog, which + may be limited to administrators. +3. Inconclusive results that a retry may resolve: no response, a 5xx status, + HTTP 429, or a successful catalog or contract response that is not readable + JSON or declares no OpenAPI version (`UnreadableResponse`). +4. `UnsupportedContract`: a JSON contract that declares a version other than + OpenAPI 3, such as Swagger 2, or an OpenAPI 3 contract that cannot be + compiled. + +A 404, another definitive status, a non-JSON response at a static contract +location, or an empty or non-advertising OCS API Viewer catalog is absence and +records nothing. When the App Store then completes without a contract, any +recorded server result takes precedence over every App Store no-contract +reason. A definitive server absence keeps the App Store reason. Diagnostics +record both sources either way, and never include request URLs, headers, or +response bodies. ## Read failures diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index ee1c2b541..09610b013 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -67,6 +67,12 @@ enum class DynamicContractFallbackReason { /** The same-origin server probe was inconclusive and the App Store yielded no contract. */ ServerProbeInconclusive, + + /** The server rejected the account's credentials during the probe (HTTP 401). */ + ServerAuthenticationRequired, + + /** The account may not read the server's API catalog or contract (HTTP 403). */ + ServerPermissionDenied, VersionUnavailable, AppStoreUnreachable, AppStoreUnavailable, @@ -106,19 +112,28 @@ internal fun DynamicDescriptorDiscovery.withFallback( /** * Keeps the metadata fallback when the App Store boundary completed without a contract. * - * Precedence: an inconclusive same-origin probe wins over every App Store no-contract reason, - * because the server may still offer a contract and checking again can help. A definitive server - * absence leaves the App Store reason in charge. Diagnostics keep both sources. + * Precedence: any same-origin probe result wins over every App Store no-contract reason, because + * the server may still offer a contract, or offers one that explains the outcome better. Among probe + * results, access problems come first, then inconclusive results, then an unsupported contract + * (see [DynamicServerProbeFailure]). A definitive server absence leaves the App Store reason in + * charge. Diagnostics keep both sources. */ internal fun DynamicDescriptorDiscovery.withUnavailable( reason: AppStoreContractUnavailableReason, appId: String, coreVersion: String, ): DynamicDescriptorDiscovery = withFallback( - fallbackReason.takeIf { it == DynamicContractFallbackReason.ServerProbeInconclusive } ?: reason.fallbackReason(), + fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: reason.fallbackReason(), "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", ) +private val SERVER_PROBE_FALLBACK_REASONS = setOf( + DynamicContractFallbackReason.ServerAuthenticationRequired, + DynamicContractFallbackReason.ServerPermissionDenied, + DynamicContractFallbackReason.ServerProbeInconclusive, + DynamicContractFallbackReason.UnsupportedContract, +) + /** Shown when discovery itself fails before producing even a metadata fallback. */ internal const val DYNAMIC_DISCOVERY_FAILURE_MESSAGE = "Could not check this app's native API. Try again." @@ -165,6 +180,18 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "Store offered no verified contract. Check the connection and try again.", "Try again", ) + DynamicContractFallbackReason.ServerAuthenticationRequired -> DynamicContractFallbackNotice( + "Sign in again", + "The server did not accept this account while checking for this app's API, and the App Store " + + "offered no verified contract. Sign in again to continue.", + "Try again", + ) + DynamicContractFallbackReason.ServerPermissionDenied -> DynamicContractFallbackNotice( + "Server API not permitted", + "Your account does not have permission to read this server's API catalog, which may be limited " + + "to administrators, and the App Store offered no verified contract. Only app details are shown.", + "Check again", + ) DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( "App version not confirmed", "The server or app version could not be confirmed, so no matching signed App Store release " + @@ -190,7 +217,7 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal ) DynamicContractFallbackReason.UnsupportedContract -> DynamicContractFallbackNotice( "API contract not supported", - "This app's verified API contract cannot be shown as a native view yet. Only app details are shown.", + "This app's API contract cannot be shown as a native view yet. Only app details are shown.", "Check again", ) DynamicContractFallbackReason.ClientFault -> DynamicContractFallbackNotice( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt index 74d98ffa0..d9c76ebd0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt @@ -87,6 +87,8 @@ private fun String.safeDiagnosticCode(): String? = when { contains("static OpenAPI", ignoreCase = true) -> "static-contract-unavailable" contains("App Store contract acquisition failed", ignoreCase = true) -> "app-store-acquisition-failed" contains("server API probe was inconclusive", ignoreCase = true) -> "server-probe-inconclusive" + contains("server API probe was not authorized", ignoreCase = true) -> "server-probe-access-denied" + contains("server API probe found an unsupported contract", ignoreCase = true) -> "server-contract-unsupported" contains("derived", ignoreCase = true) && contains("read-only endpoints", ignoreCase = true) -> "verified-static-read-routes" contains("verified read", ignoreCase = true) && contains("fallback routes", ignoreCase = true) -> diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index d1c20ca91..34eb9b0d8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -12,35 +12,72 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull -/** Why a same-origin catalog or contract probe could not prove that the server offers no contract. */ -enum class DynamicServerProbeFailure { +/** + * Why a same-origin catalog or contract probe could not prove that the server offers no contract. + * + * [rank] orders results when probes disagree: access problems first, because signing in or gaining + * permission can change every other answer; then inconclusive results that a retry may resolve; + * then a definitive but unusable contract. A definitive absence, such as 404, records nothing. + */ +enum class DynamicServerProbeFailure(internal val rank: Int) { + /** The server rejected the account's credentials (HTTP 401). */ + AuthenticationRequired(0), + + /** The account may not read the catalog or contract, for example an administrator-only endpoint (HTTP 403). */ + PermissionDenied(1), + /** The request produced no HTTP response. */ - NoResponse, + NoResponse(2), - /** The server answered with a 5xx status or an unreadable success body. */ - ServerError, + /** The server answered with a 5xx status. */ + ServerError(2), /** The server rate limited the request (HTTP 429). */ - Throttled, + Throttled(2), + + /** A successful catalog or contract response was not readable JSON or carried no OpenAPI version. */ + UnreadableResponse(2), + + /** The server offered a contract that is not OpenAPI 3, such as Swagger 2, or that cannot be compiled. */ + UnsupportedContract(3), + ; + + internal fun isInconclusive(): Boolean = rank == 2 } -/** Keeps the first inconclusive probe; a definitive answer such as 404 never records anything. */ +/** Keeps the highest-precedence probe result; among equal ranks the first result wins. */ private class DynamicServerProbe { var failure: DynamicServerProbeFailure? = null private set fun record(failure: DynamicServerProbeFailure) { - if (this.failure == null) this.failure = failure + if (this.failure.let { current -> current == null || failure.rank < current.rank }) this.failure = failure } fun record(response: NextcloudApiResponse) { when { + response.status == 401 -> record(DynamicServerProbeFailure.AuthenticationRequired) + response.status == 403 -> record(DynamicServerProbeFailure.PermissionDenied) response.status == 429 -> record(DynamicServerProbeFailure.Throttled) response.status in 500..599 -> record(DynamicServerProbeFailure.ServerError) } } } +/** Diagnostic text and contract-info code derive from the typed result, never from response content. */ +private fun DynamicServerProbeFailure.diagnostic(): String = when { + isInconclusive() -> "The server API probe was inconclusive: $name." + this == DynamicServerProbeFailure.UnsupportedContract -> "The server API probe found an unsupported contract." + else -> "The server API probe was not authorized: $name." +} + +private fun DynamicServerProbeFailure.fallbackReason(): DynamicContractFallbackReason = when (this) { + DynamicServerProbeFailure.AuthenticationRequired -> DynamicContractFallbackReason.ServerAuthenticationRequired + DynamicServerProbeFailure.PermissionDenied -> DynamicContractFallbackReason.ServerPermissionDenied + DynamicServerProbeFailure.UnsupportedContract -> DynamicContractFallbackReason.UnsupportedContract + else -> DynamicContractFallbackReason.ServerProbeInconclusive +} + internal suspend fun discoverDynamicAppDescriptor( serverUrl: String, app: NextcloudAppEntry, @@ -90,7 +127,7 @@ internal suspend fun discoverDynamicAppDescriptor( } val document = response.parseOpenApiDocument() if (document == null) { - probe.record(DynamicServerProbeFailure.ServerError) + probe.record(response.contractBodyFailure()) diagnostics += "OCS API Viewer returned an invalid OpenAPI JSON body for $specId${response.contentTypeDiagnostic()}." return@forEach } @@ -103,6 +140,7 @@ internal suspend fun discoverDynamicAppDescriptor( val descriptor = runCatchingPreservingCancellation { compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) }.onFailure { failure -> + probe.record(DynamicServerProbeFailure.UnsupportedContract) diagnostics += "OCS API Viewer returned an unusable specification for $specId: " + (failure.message ?: "compilation failed") + "." }.getOrNull() ?: return@forEach @@ -134,7 +172,12 @@ internal suspend fun discoverDynamicAppDescriptor( probe.record(response) return@forEach } - val document = response.parseOpenApiDocument() ?: return@forEach + // A JSON response at a contract location is evidence; only a non-JSON response is absence. + val document = response.parseOpenApiDocument() + if (document == null) { + probe.record(response.contractBodyFailure()) + return@forEach + } onProgress( DynamicDescriptorDiscoveryProgress( DynamicDescriptorDiscoveryPhase.NativeWorkspace, @@ -143,7 +186,7 @@ internal suspend fun discoverDynamicAppDescriptor( ) val descriptor = runCatchingPreservingCancellation { compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) - }.getOrNull() ?: return@forEach + }.onFailure { probe.record(DynamicServerProbeFailure.UnsupportedContract) }.getOrNull() ?: return@forEach return DynamicDescriptorDiscovery( descriptor = descriptor, sourcePath = path, @@ -153,7 +196,7 @@ internal suspend fun discoverDynamicAppDescriptor( } diagnostics += "No valid static OpenAPI document was found in the app's advertised asset locations." - probe.failure?.let { failure -> diagnostics += "The server API probe was inconclusive: ${failure.name}." } + probe.failure?.let { failure -> diagnostics += failure.diagnostic() } onProgress( DynamicDescriptorDiscoveryProgress( @@ -171,7 +214,7 @@ internal suspend fun discoverDynamicAppDescriptor( sourcePath = null, acquisition = DynamicDescriptorAcquisition.MetadataFallback, diagnostics = diagnostics, - fallbackReason = probe.failure?.let { DynamicContractFallbackReason.ServerProbeInconclusive }, + fallbackReason = probe.failure?.fallbackReason(), ) } @@ -200,7 +243,7 @@ private suspend fun discoverOfficialViewerSpecIds( val catalog = runCatchingPreservingCancellation { dynamicJson.parseToJsonElement(response.body.decodeToString()) as? JsonArray } .getOrNull() if (catalog == null) { - probe.record(DynamicServerProbeFailure.ServerError) + probe.record(DynamicServerProbeFailure.UnreadableResponse) diagnostics += "OCS API Viewer returned an invalid app catalog${response.contentTypeDiagnostic()}." return emptyList() } @@ -236,6 +279,17 @@ private fun NextcloudApiResponse.parseOpenApiDocument(): JsonObject? { return document.takeIf { version?.startsWith("3.") == true } } +/** Classifies a successful contract body that [parseOpenApiDocument] rejected. */ +private fun NextcloudApiResponse.contractBodyFailure(): DynamicServerProbeFailure { + val root = runCatching { dynamicJson.parseToJsonElement(body.decodeToString()) as? JsonObject }.getOrNull() + val declaredVersion = (root?.get("openapi") ?: root?.get("swagger")) as? JsonPrimitive + return if (declaredVersion?.contentOrNull != null) { + DynamicServerProbeFailure.UnsupportedContract + } else { + DynamicServerProbeFailure.UnreadableResponse + } +} + private fun NextcloudApiResponse.contentTypeDiagnostic(): String = contentType?.let { " (Content-Type: $it)" } ?: " (without a Content-Type header)" diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt index cc1674637..c8c09379a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -13,12 +13,30 @@ class DynamicServerProbeFallbackTest { private val session = NextcloudSession("https://fixture.invalid", "fixture", "synthetic") private val app = NextcloudAppEntry("example", "Example", "/apps/example/") - private enum class ServerProbe(val failure: DynamicServerProbeFailure?) { - Absent(null), - EmptyCatalog(null), - Network(DynamicServerProbeFailure.NoResponse), - ServerError(DynamicServerProbeFailure.ServerError), - Throttled(DynamicServerProbeFailure.Throttled), + /** One server behavior per scenario, with the probe result and fallback reason it must produce. */ + private enum class ServerProbe( + val failure: DynamicServerProbeFailure?, + val reason: DynamicContractFallbackReason?, + val diagnosticCode: String?, + ) { + Absent(null, null, null), + EmptyCatalog(null, null, null), + Network(DynamicServerProbeFailure.NoResponse, DynamicContractFallbackReason.ServerProbeInconclusive, INCONCLUSIVE), + ServerError(DynamicServerProbeFailure.ServerError, DynamicContractFallbackReason.ServerProbeInconclusive, INCONCLUSIVE), + Throttled(DynamicServerProbeFailure.Throttled, DynamicContractFallbackReason.ServerProbeInconclusive, INCONCLUSIVE), + MalformedStaticSpec( + DynamicServerProbeFailure.UnreadableResponse, DynamicContractFallbackReason.ServerProbeInconclusive, INCONCLUSIVE, + ), + UnversionedStaticJson( + DynamicServerProbeFailure.UnreadableResponse, DynamicContractFallbackReason.ServerProbeInconclusive, INCONCLUSIVE, + ), + SwaggerStaticSpec( + DynamicServerProbeFailure.UnsupportedContract, DynamicContractFallbackReason.UnsupportedContract, UNSUPPORTED, + ), + Unauthorized( + DynamicServerProbeFailure.AuthenticationRequired, DynamicContractFallbackReason.ServerAuthenticationRequired, DENIED, + ), + Forbidden(DynamicServerProbeFailure.PermissionDenied, DynamicContractFallbackReason.ServerPermissionDenied, DENIED), } @Test @@ -26,68 +44,96 @@ class DynamicServerProbeFallbackTest { for (probe in ServerProbe.entries) { for (unavailable in AppStoreContractUnavailableReason.entries) { val discovery = discover(probe) { AppStoreContractAcquisition.Unavailable(unavailable) } - val expected = if (probe.failure == null) { - unavailable.fallbackReason() - } else { - DynamicContractFallbackReason.ServerProbeInconclusive - } val label = "$probe x $unavailable" assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition, label) - assertEquals(expected, discovery.fallbackReason, label) + // Any server probe result outranks an App Store no-contract outcome; absence defers to it. + assertEquals(probe.reason ?: unavailable.fallbackReason(), discovery.fallbackReason, label) // Diagnostics keep both sources regardless of which one decided the copy. assertTrue(discovery.diagnostics.any { unavailable.name in it && "No App Store contract" in it }, label) - val serverDiagnostics = discovery.diagnostics.filter { "probe was inconclusive" in it } - assertEquals(listOfNotNull(probe.failure?.let { "The server API probe was inconclusive: ${it.name}." }), - serverDiagnostics, label) + val serverDiagnostics = discovery.diagnostics.filter { "server API probe" in it } + assertEquals(if (probe.failure == null) 0 else 1, serverDiagnostics.size, label) val codes = discovery.toContractInfo(null).diagnosticCodes - assertEquals(probe.failure != null, "server-probe-inconclusive" in codes, label) + assertEquals(listOfNotNull(probe.diagnosticCode), codes.filter { it.startsWith("server-") }, label) } } } @Test - fun definitiveAbsenceKeepsThePermanentCopyAndAnInconclusiveProbeIsRetryable() = runBlocking { - val absent = discover(ServerProbe.Absent) { - AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) + fun sameOriginDiscoveryRecordsTheTypedProbeResultForEachScenario() = runBlocking { + for (probe in ServerProbe.entries) { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = server(probe)) + assertEquals(probe.reason, discovery.fallbackReason, probe.name) + probe.failure?.let { failure -> + assertTrue(discovery.diagnostics.any { failure.name in it || "unsupported contract" in it }, probe.name) + } + assertTrue(discovery.diagnostics.none { "fixture.invalid" in it && "probe" in it }, probe.name) + } + } + + @Test + fun accessProblemsOutrankInconclusiveResultsWhichOutrankUnsupportedContracts() = runBlocking { + fun statuses(viewer: Int, static: Int, staticBody: String = ""): suspend (NextcloudApiRequest) -> NextcloudApiResponse = + { request -> + if (request.relativePath.contains("ocs_api_viewer")) { + response(viewer) + } else { + response(static, staticBody) + } + } + val cases = listOf( + statuses(403, 503) to DynamicContractFallbackReason.ServerPermissionDenied, + statuses(503, 401) to DynamicContractFallbackReason.ServerAuthenticationRequired, + statuses(403, 401) to DynamicContractFallbackReason.ServerAuthenticationRequired, + statuses(401, 403) to DynamicContractFallbackReason.ServerAuthenticationRequired, + statuses(429, 200, SWAGGER) to DynamicContractFallbackReason.ServerProbeInconclusive, + statuses(404, 200, SWAGGER) to DynamicContractFallbackReason.UnsupportedContract, + statuses(403, 200, SWAGGER) to DynamicContractFallbackReason.ServerPermissionDenied, + statuses(404, 404) to null, + ) + for ((execute, expected) in cases) { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = execute) + assertEquals(expected, discovery.fallbackReason) } - assertEquals(DynamicContractFallbackReason.NoVerifiedContract, absent.fallbackReason) - val inconclusive = DynamicContractFallbackReason.ServerProbeInconclusive.fallbackNotice() - assertEquals("Try again", inconclusive.actionLabel) - assertTrue("Neither" !in inconclusive.message) } @Test fun oneInconclusiveStaticProbeAmongDefinitiveAnswersStillMakesTheServerInconclusive() = runBlocking { val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> - val status = if (request.relativePath == "/custom_apps/example/openapi.json") 503 else 404 - NextcloudApiResponse(status, ByteArray(0), "application/json", etag = null) + response(if (request.relativePath == "/custom_apps/example/openapi.json") 503 else 404) }) assertEquals(DynamicContractFallbackReason.ServerProbeInconclusive, discovery.fallbackReason) assertTrue("The server API probe was inconclusive: ServerError." in discovery.diagnostics) } @Test - fun theFirstInconclusiveProbeIsRecordedAndDefinitiveProbesRecordNothing() = runBlocking { - for (probe in ServerProbe.entries) { - val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = server(probe)) - val expected = probe.failure?.let { DynamicContractFallbackReason.ServerProbeInconclusive } - assertEquals(expected, discovery.fallbackReason, probe.name) - } - val catalogThrottledThenFailing = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> - if (request.relativePath.contains("ocs_api_viewer")) { - NextcloudApiResponse(429, ByteArray(0), "application/json", etag = null) - } else { - throw IllegalStateException("synthetic transport failure") - } + fun theFirstResultWinsAmongEqualRanks() = runBlocking { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> + if (request.relativePath.contains("ocs_api_viewer")) response(429) else throw IllegalStateException("synthetic") }) assertEquals( listOf("The server API probe was inconclusive: Throttled."), - catalogThrottledThenFailing.diagnostics.filter { "probe was inconclusive" in it }, + discovery.diagnostics.filter { "server API probe" in it }, ) } @Test - fun cancellationDuringAServerProbeIsNotRecordedAsInconclusive() = runBlocking { + fun accessAndUnsupportedCopyGivesTheRightNextStepWithoutRequestDetails() { + val authentication = DynamicContractFallbackReason.ServerAuthenticationRequired.fallbackNotice() + assertTrue("Sign in again to continue." in authentication.message) + val permission = DynamicContractFallbackReason.ServerPermissionDenied.fallbackNotice() + assertTrue("permission" in permission.message && "administrators" in permission.message) + val unsupported = DynamicContractFallbackReason.UnsupportedContract.fallbackNotice() + assertTrue("verified" !in unsupported.message) + for (notice in listOf(authentication, permission, unsupported)) { + assertTrue(listOf("http", "/", "401", "403").none { it in notice.message }, notice.message) + } + val inconclusive = DynamicContractFallbackReason.ServerProbeInconclusive.fallbackNotice() + assertEquals("Try again", inconclusive.actionLabel) + assertTrue("Neither" !in inconclusive.message) + } + + @Test + fun cancellationDuringAServerProbeIsNotRecorded() = runBlocking { val cancelled = CancellationException("stop") assertSame(cancelled, assertFailsWith { discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { throw cancelled }) @@ -95,16 +141,18 @@ class DynamicServerProbeFallbackTest { } private fun server(probe: ServerProbe): suspend (NextcloudApiRequest) -> NextcloudApiResponse = { request -> + val viewer = request.relativePath.contains("ocs_api_viewer") when (probe) { - ServerProbe.Absent -> NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) - ServerProbe.EmptyCatalog -> if (request.relativePath.contains("ocs_api_viewer")) { - NextcloudApiResponse(200, "[]".encodeToByteArray(), "application/json", etag = null) - } else { - NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) - } + ServerProbe.Absent -> response(404) + ServerProbe.EmptyCatalog -> if (viewer) response(200, "[]") else response(404) ServerProbe.Network -> throw IllegalStateException("synthetic transport failure") - ServerProbe.ServerError -> NextcloudApiResponse(503, ByteArray(0), "application/json", etag = null) - ServerProbe.Throttled -> NextcloudApiResponse(429, ByteArray(0), "application/json", etag = null) + ServerProbe.ServerError -> response(503) + ServerProbe.Throttled -> response(429) + ServerProbe.MalformedStaticSpec -> if (viewer) response(404) else response(200, """{"openapi":"3.0""") + ServerProbe.UnversionedStaticJson -> if (viewer) response(404) else response(200, """{"status":"ok"}""") + ServerProbe.SwaggerStaticSpec -> if (viewer) response(404) else response(200, SWAGGER) + ServerProbe.Unauthorized -> response(401, """{"message":"Current user is not logged in"}""") + ServerProbe.Forbidden -> if (viewer) response(403) else response(404) } } @@ -141,4 +189,14 @@ class DynamicServerProbeFallbackTest { ): AppStoreContractAcquisition = acquire() } } + + private companion object { + const val INCONCLUSIVE = "server-probe-inconclusive" + const val UNSUPPORTED = "server-contract-unsupported" + const val DENIED = "server-probe-access-denied" + const val SWAGGER = """{"swagger":"2.0","info":{"title":"Example","version":"1"},"paths":{}}""" + + fun response(status: Int, body: String = ""): NextcloudApiResponse = + NextcloudApiResponse(status, body.encodeToByteArray(), "application/json", etag = null) + } } From 7853aeabd528bf3d81b6d9648c08f1b5a5e09e67 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:35 +0000 Subject: [PATCH 18/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9c7e4096d..c319b658e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,8 +585,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "5cb677f5ebe7dfef4118a9221bba9cfdbb6f7416a0e6bccc6405bdf22bcc4b77", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "ab8c2bb9a6ef6f5e194536740f638923b257db509b16f3ee860ece13fc8a1a57", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "4c3d6810c51d344c70bb1113248c345f19ac387f557c93e62ef0578f1e83d1f1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "79bb347d92be621079008d3ab4fcde43167fe9d37e182e6762e100f3ad2d1215", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationCache.kt": "af27d14641fde05a5c1d1688676102c6630ccce4cd8d06ab0e3d58c95432aa8f", @@ -600,7 +600,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "d2aff4e95e80ee54e81be01a9bc055728d87a3fde9981c82ee30b269b04461da", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "0d316600af61ef82cac3cf862a81334fc889d183284906705dc02b39d60ad1df", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", From 5afd408883382c0de8884727e0974d89a201e467 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 03:01:32 +0200 Subject: [PATCH 19/49] refactor(contracts): move signed package tar reading into its own file --- .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 1 + .../SignedAppStoreContractAcquirer.kt | 124 +---------------- .../contracts/SignedPackageTarReader.kt | 126 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 1 - 4 files changed, 128 insertions(+), 124 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 1feb159f9..d32242fa2 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -58,6 +58,7 @@ class AppOwnedOpenApiAndroidInstrumentedTest { "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "AppStoreCatalogReleasesKt", "ContractAcquisitionFailureKt", "LinkedSourceArchiveKt", "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", + "SignedPackageTarReaderKt", "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", "FileAppStoreCatalogCache", "DynamicApiResponseCache", "FileVerifiedContractCache", diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 632f00d67..98a74b943 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -2,13 +2,11 @@ package dev.obiente.nextcloudnative.contracts import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.InputStream import java.net.URI import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit -import java.util.zip.GZIPInputStream import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -647,119 +645,6 @@ internal fun parseAppInfoIdentity(infoXml: String): Pair { return requiredElement("id") to requiredElement("version") } -private fun readSelectedTarFiles(archive: ByteArray): Map { - val selected = linkedMapOf() - GZIPInputStream(ByteArrayInputStream(archive)).use { input -> - var totalUncompressed = 0L - var pendingLongPath: String? = null - while (true) { - val header = input.readExactlyOrNull(TAR_BLOCK_BYTES) ?: break - if (header.all { byte -> byte == 0.toByte() }) break - val name = header.tarText(0, 100) - val prefix = header.tarText(345, 155) - val headerName = if (prefix.isBlank()) name else "$prefix/$name" - val size = header.tarOctal(124, 12) - check(size in 0..MAX_TAR_ENTRY_BYTES) { "An app package entry is too large." } - totalUncompressed += size - check(totalUncompressed <= MAX_TAR_UNCOMPRESSED_BYTES) { "The app package expands beyond the safety limit." } - val type = header[156].toInt().toChar() - if (type == 'L') { - check(size <= MAX_TAR_PATH_BYTES) { "An app package path exceeds the safety limit." } - pendingLongPath = decodeTarLongPath(input.readExactly(size.toInt())) - val longPathPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES - input.skipExactly(longPathPadding) - continue - } - if (type in TAR_METADATA_TYPES) { - input.skipExactly(size) - val metadataPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES - input.skipExactly(metadataPadding) - continue - } - val fullName = pendingLongPath ?: headerName - pendingLongPath = null - if (isCanonicalTarRootDirectory(fullName, type)) { - input.skipExactly(size) - val rootPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES - input.skipExactly(rootPadding) - continue - } - validateArchivePath(fullName) - val staticMetadata = fullName.endsWith("/appinfo/routes.php") || - (fullName.contains("/lib/Controller/") && fullName.endsWith("Controller.php")) - val keep = (type == '\u0000' || type == '0') && ( - fullName.endsWith("/appinfo/info.xml") || - fullName.substringAfterLast('/').matches(OPEN_API_FILE_PATTERN) || - staticMetadata - ) - if (keep) { - val selectedFileLimit = if (staticMetadata) MAX_STATIC_METADATA_FILE_BYTES else MAX_SELECTED_FILE_BYTES - check(size <= selectedFileLimit) { "A contract metadata file exceeds the safety limit." } - check(selected.size < MAX_SELECTED_ARCHIVE_FILES) { "The app package has too many contract metadata files." } - selected[fullName] = input.readExactly(size.toInt()) - } else { - input.skipExactly(size) - } - val padding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES - input.skipExactly(padding) - } - check(pendingLongPath == null) { "The app package ends with an incomplete path record." } - } - return selected -} - -private fun validateArchivePath(path: String) { - require(path.isNotBlank() && !path.startsWith('/') && '\\' !in path) { "The app package contains an unsafe path." } - require(path.split('/').none { segment -> segment == "." || segment == ".." }) { - "The app package contains a traversing path." - } -} - -private fun ByteArray.tarText(offset: Int, length: Int): String = - copyOfRange(offset, offset + length).takeWhile { byte -> byte != 0.toByte() }.toByteArray() - .decodeToString() - .trim() - -private fun ByteArray.tarOctal(offset: Int, length: Int): Long { - val value = tarText(offset, length).trim().trimStart('0') - return if (value.isEmpty()) 0L else value.toLong(8) -} - -private fun InputStream.readExactlyOrNull(length: Int): ByteArray? { - val first = read() - if (first == -1) return null - val result = ByteArray(length) - result[0] = first.toByte() - var offset = 1 - while (offset < length) { - val read = read(result, offset, length - offset) - check(read > 0) { "The app package is truncated." } - offset += read - } - return result -} - -private fun InputStream.readExactly(length: Int): ByteArray { - val result = ByteArray(length) - var offset = 0 - while (offset < length) { - val read = read(result, offset, length - offset) - check(read > 0) { "The app package is truncated." } - offset += read - } - return result -} - -private fun InputStream.skipExactly(length: Long) { - var remaining = length - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (remaining > 0) { - val read = read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) - check(read > 0) { "The app package is truncated." } - remaining -= read - } -} - private fun Response.readBodyLimited(maximumBytes: Long): ByteArray { val responseBody = body val declaredLength = responseBody.contentLength() @@ -786,16 +671,9 @@ private fun URI.effectivePort(): Int = when { else -> -1 } -private const val TAR_BLOCK_BYTES = 512 -private const val MAX_TAR_ENTRY_BYTES = 128L * 1024L * 1024L -private const val MAX_TAR_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L internal const val MAX_SELECTED_FILE_BYTES = 16L * 1024L * 1024L -private const val MAX_STATIC_METADATA_FILE_BYTES = 1L * 1024L * 1024L -private const val MAX_SELECTED_ARCHIVE_FILES = 320 private const val MAX_OPEN_API_CANDIDATES = 64 private const val MAX_EXTERNAL_SCHEMA_DOCUMENTS = 16 private const val MAX_EXTERNAL_REFERENCE_DEPTH = 24 -private val OPEN_API_FILE_PATTERN = Regex("(?i)openapi(?:[-_][A-Za-z0-9][A-Za-z0-9._-]*)?\\.(?:json|yaml|yml)") +internal val OPEN_API_FILE_PATTERN = Regex("(?i)openapi(?:[-_][A-Za-z0-9][A-Za-z0-9._-]*)?\\.(?:json|yaml|yml)") private val SAFE_REPOSITORY_PATH_SEGMENT = Regex("[A-Za-z0-9_.-]+") -private val TAR_METADATA_TYPES = setOf('g', 'x', 'K') -private const val MAX_TAR_PATH_BYTES = 4_096L diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt new file mode 100644 index 000000000..6550d12a7 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt @@ -0,0 +1,126 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.util.zip.GZIPInputStream + +internal fun readSelectedTarFiles(archive: ByteArray): Map { + val selected = linkedMapOf() + GZIPInputStream(ByteArrayInputStream(archive)).use { input -> + var totalUncompressed = 0L + var pendingLongPath: String? = null + while (true) { + val header = input.readExactlyOrNull(TAR_BLOCK_BYTES) ?: break + if (header.all { byte -> byte == 0.toByte() }) break + val name = header.tarText(0, 100) + val prefix = header.tarText(345, 155) + val headerName = if (prefix.isBlank()) name else "$prefix/$name" + val size = header.tarOctal(124, 12) + check(size in 0..MAX_TAR_ENTRY_BYTES) { "An app package entry is too large." } + totalUncompressed += size + check(totalUncompressed <= MAX_TAR_UNCOMPRESSED_BYTES) { "The app package expands beyond the safety limit." } + val type = header[156].toInt().toChar() + if (type == 'L') { + check(size <= MAX_TAR_PATH_BYTES) { "An app package path exceeds the safety limit." } + pendingLongPath = decodeTarLongPath(input.readExactly(size.toInt())) + val longPathPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES + input.skipExactly(longPathPadding) + continue + } + if (type in TAR_METADATA_TYPES) { + input.skipExactly(size) + val metadataPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES + input.skipExactly(metadataPadding) + continue + } + val fullName = pendingLongPath ?: headerName + pendingLongPath = null + if (isCanonicalTarRootDirectory(fullName, type)) { + input.skipExactly(size) + val rootPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES + input.skipExactly(rootPadding) + continue + } + validateArchivePath(fullName) + val staticMetadata = fullName.endsWith("/appinfo/routes.php") || + (fullName.contains("/lib/Controller/") && fullName.endsWith("Controller.php")) + val keep = (type == '\u0000' || type == '0') && ( + fullName.endsWith("/appinfo/info.xml") || + fullName.substringAfterLast('/').matches(OPEN_API_FILE_PATTERN) || + staticMetadata + ) + if (keep) { + val selectedFileLimit = if (staticMetadata) MAX_STATIC_METADATA_FILE_BYTES else MAX_SELECTED_FILE_BYTES + check(size <= selectedFileLimit) { "A contract metadata file exceeds the safety limit." } + check(selected.size < MAX_SELECTED_ARCHIVE_FILES) { "The app package has too many contract metadata files." } + selected[fullName] = input.readExactly(size.toInt()) + } else { + input.skipExactly(size) + } + val padding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES + input.skipExactly(padding) + } + check(pendingLongPath == null) { "The app package ends with an incomplete path record." } + } + return selected +} + +private fun validateArchivePath(path: String) { + require(path.isNotBlank() && !path.startsWith('/') && '\\' !in path) { "The app package contains an unsafe path." } + require(path.split('/').none { segment -> segment == "." || segment == ".." }) { + "The app package contains a traversing path." + } +} + +private fun ByteArray.tarText(offset: Int, length: Int): String = + copyOfRange(offset, offset + length).takeWhile { byte -> byte != 0.toByte() }.toByteArray() + .decodeToString() + .trim() + +private fun ByteArray.tarOctal(offset: Int, length: Int): Long { + val value = tarText(offset, length).trim().trimStart('0') + return if (value.isEmpty()) 0L else value.toLong(8) +} + +private fun InputStream.readExactlyOrNull(length: Int): ByteArray? { + val first = read() + if (first == -1) return null + val result = ByteArray(length) + result[0] = first.toByte() + var offset = 1 + while (offset < length) { + val read = read(result, offset, length - offset) + check(read > 0) { "The app package is truncated." } + offset += read + } + return result +} + +private fun InputStream.readExactly(length: Int): ByteArray { + val result = ByteArray(length) + var offset = 0 + while (offset < length) { + val read = read(result, offset, length - offset) + check(read > 0) { "The app package is truncated." } + offset += read + } + return result +} + +private fun InputStream.skipExactly(length: Long) { + var remaining = length + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (remaining > 0) { + val read = read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) + check(read > 0) { "The app package is truncated." } + remaining -= read + } +} + +private const val TAR_BLOCK_BYTES = 512 +private const val MAX_TAR_ENTRY_BYTES = 128L * 1024L * 1024L +private const val MAX_TAR_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L +private const val MAX_STATIC_METADATA_FILE_BYTES = 1L * 1024L * 1024L +private const val MAX_SELECTED_ARCHIVE_FILES = 320 +private val TAR_METADATA_TYPES = setOf('g', 'x', 'K') +private const val MAX_TAR_PATH_BYTES = 4_096L diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 3096d3d6d..7917a5ab3 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,6 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|801 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 From 25c9ff40f6ad642a98468e9843d572d623d1bff0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 03:13:42 +0200 Subject: [PATCH 20/49] fix: refresh the App Store catalog on an explicit discovery retry The App Store catalog cache is kept for six hours, so pressing Check again or Try again reread the same catalog and could not see a newly published release or a corrected entry. Discovery now carries a typed catalog policy through the platform boundary: the first discovery of an app screen uses the cache, and a discovery started by or after an explicit retry asks the acquirer for a fresh catalog. The acquirer downloads each catalog again at most once per minute per URL, and a failed or unreadable forced download leaves the cached catalog in place. --- DYNAMIC_APP_DESCRIPTOR.md | 8 ++ .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 6 +- .../AndroidNextcloudServices.kt | 14 +- .../contracts/AppStoreCatalogCache.kt | 29 ++++ .../SignedAppStoreContractAcquirer.kt | 36 +++-- .../contracts/CatalogRefreshTest.kt | 131 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 6 +- .../app/DynamicContractFallback.kt | 28 ++++ .../app/DynamicNativeRuntime.kt | 2 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 2 +- .../nextcloudnative/app/NextcloudPlatform.kt | 6 +- .../app/DesktopNextcloudServices.kt | 13 +- .../app/AppStoreCatalogRetryTest.kt | 60 ++++++++ .../DynamicContractFallbackDiscoveryTest.kt | 7 +- .../app/DynamicServerProbeFallbackTest.kt | 7 +- .../app/JvmAppStoreContractAcquisitionTest.kt | 30 +++- .../app/JvmAppStoreContractAcquisition.kt | 11 +- 17 files changed, 326 insertions(+), 70 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index fe7a5eb9e..c34f5ea2b 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -227,6 +227,14 @@ a malformed source; it is never cached, so checking again downloads it again. A corrupt or mismatched App Store-linked source archive yields no candidate, like an archive without a contract, instead of an unexpected client fault. +App Store catalogs are cached for six hours. The first discovery of an app +screen uses the cache. A discovery started by, or after, an explicit retry on +that screen carries `AppStoreCatalogPolicy.RefreshCatalog` through the platform +boundary to the acquirer, which downloads each catalog again at most once per +minute per catalog URL; retries inside that window reuse the cached copy. A +forced download that fails or is unreadable leaves the cached catalog in place, +because only a readable catalog replaces it. + [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. When probes disagree, the highest-precedence result wins, and the first one diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index d32242fa2..96e049219 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -5,9 +5,9 @@ import androidx.test.platform.app.InstrumentationRegistry import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition import dev.obiente.nextcloudnative.app.AppStoreContractAcquisitionException import dev.obiente.nextcloudnative.app.AppStoreContractFailureKind +import dev.obiente.nextcloudnative.app.AppStoreContractRequest import dev.obiente.nextcloudnative.app.AppStoreContractUnavailableReason import dev.obiente.nextcloudnative.app.acquireAppStoreContract -import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer import java.io.File @@ -66,7 +66,7 @@ class AppOwnedOpenApiAndroidInstrumentedTest { Class.forName("dev.obiente.nextcloudnative.app.JvmAppStoreContractAcquisitionKt") val failed = try { - acquireAppStoreContract(ContractAcquisitionRequest("example", "34.0.3", "1.0.0")) { + acquireAppStoreContract(AppStoreContractRequest("example", "34.0.3", "1.0.0")) { _, _ -> throw NoClassDefFoundError("dev.obiente.nextcloudnative.contracts.SyntheticKt") } null @@ -94,7 +94,7 @@ class AppOwnedOpenApiAndroidInstrumentedTest { catalogCache = FileAppStoreCatalogCache(File(File(cacheRoot, "blocked"), "catalogs")), ) val outcome = acquireAppStoreContract( - ContractAcquisitionRequest("example", "34.0.3", "1.0.0"), + AppStoreContractRequest("example", "34.0.3", "1.0.0"), acquirer::acquireOutcome, ) assertEquals( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index d38fd23c2..be981a0d4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,6 +14,7 @@ import android.provider.Settings import android.util.Base64 import android.util.Log import dev.obiente.nextcloudnative.app.AppStoreContractAcquisition +import dev.obiente.nextcloudnative.app.AppStoreContractRequest import dev.obiente.nextcloudnative.app.acquireAppStoreContract import dev.obiente.nextcloudnative.app.DeckAttachment import dev.obiente.nextcloudnative.app.DeckAttachmentOpenTarget @@ -229,7 +230,6 @@ import dev.obiente.nextcloudnative.app.parseTalkMessageJson import dev.obiente.nextcloudnative.app.requireSafe import dev.obiente.nextcloudnative.app.systemTagsDavDiscoveryRequest import dev.obiente.nextcloudnative.app.toWebDavMutationSpec -import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache import dev.obiente.nextcloudnative.contracts.FileVerifiedContractCache @@ -2956,16 +2956,8 @@ internal class AndroidNextcloudServices( NextcloudApiResponse(response.status, response.body, response.contentType, response.etag, response.location) } - override suspend fun acquireSignedOpenApiContract( - appId: String, - serverVersion: String, - installedAppVersion: String?, - ): AppStoreContractAcquisition = withContext(Dispatchers.IO) { - acquireAppStoreContract( - ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), - contractAcquirer::acquireOutcome, - ) - } + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer::acquireOutcome) } override suspend fun listActivities( session: NextcloudSession, diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt index c6b422077..3e7f10779 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt @@ -27,6 +27,35 @@ internal fun AppStoreCatalogCache.storeBestEffort(url: String, bytes: ByteArray) } } +/** Whether a catalog read may reuse the cached copy. Only an explicit user retry asks for [Refresh]. */ +enum class CatalogPolicy { UseCache, Refresh } + +/** + * Bounds explicit catalog refreshes to one forced download per catalog URL per interval. A retry + * inside the window reuses the cached catalog, so repeated presses cannot hammer the App Store. + * A forced download counts even when it fails. + */ +class CatalogRefreshThrottle( + private val intervalMillis: Long = DEFAULT_CATALOG_REFRESH_INTERVAL_MILLIS, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val lastRefresh = ConcurrentHashMap() + + /** Returns true, and starts a new window, when [url] may be downloaded again now. */ + fun tryRefresh(url: String): Boolean { + val now = nowMillis() + var allowed = false + lastRefresh.compute(url) { _, previous -> + if (previous == null || now - previous !in 0 until intervalMillis) now.also { allowed = true } else previous + } + return allowed + } + + companion object { + const val DEFAULT_CATALOG_REFRESH_INTERVAL_MILLIS = 60_000L + } +} + class MemoryAppStoreCatalogCache : AppStoreCatalogCache { private val entries = ConcurrentHashMap() diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 98a74b943..29bca15e8 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -27,10 +27,11 @@ class SignedAppStoreContractAcquirer( private val rawGitHubBaseUrl: String = OFFICIAL_RAW_GITHUB_BASE_URL, private val githubApiBaseUrl: String = OFFICIAL_GITHUB_API_BASE_URL, private val codeloadGitHubBaseUrl: String = OFFICIAL_CODELOAD_GITHUB_BASE_URL, + private val catalogRefreshThrottle: CatalogRefreshThrottle = CatalogRefreshThrottle(), ) { private val verifiedContracts = ConcurrentHashMap() private val inFlightAcquisitions = - ConcurrentHashMap>() + ConcurrentHashMap, CompletableFuture>() init { if (requireHttps) { @@ -50,9 +51,13 @@ class SignedAppStoreContractAcquirer( fun acquire(request: ContractAcquisitionRequest): VerifiedOpenApiContract? = (acquireOutcome(request) as? ContractAcquisitionOutcome.Acquired)?.contract - fun acquireOutcome(request: ContractAcquisitionRequest): ContractAcquisitionOutcome { + /** [catalogPolicy] is [CatalogPolicy.Refresh] only for an explicit user retry; see [CatalogRefreshThrottle]. */ + fun acquireOutcome( + request: ContractAcquisitionRequest, + catalogPolicy: CatalogPolicy = CatalogPolicy.UseCache, + ): ContractAcquisitionOutcome { val owner = CompletableFuture() - val existing = inFlightAcquisitions.putIfAbsent(request, owner) + val existing = inFlightAcquisitions.putIfAbsent(request to catalogPolicy, owner) if (existing != null) { return try { existing.get() @@ -64,16 +69,19 @@ class SignedAppStoreContractAcquirer( } } return try { - acquireUncoalesced(request).also { acquired -> owner.complete(acquired) } + acquireUncoalesced(request, catalogPolicy).also { acquired -> owner.complete(acquired) } } catch (failure: Throwable) { owner.completeExceptionally(failure) throw failure } finally { - inFlightAcquisitions.remove(request, owner) + inFlightAcquisitions.remove(request to catalogPolicy, owner) } } - private fun acquireUncoalesced(request: ContractAcquisitionRequest): ContractAcquisitionOutcome { + private fun acquireUncoalesced( + request: ContractAcquisitionRequest, + catalogPolicy: CatalogPolicy, + ): ContractAcquisitionOutcome { requireValidRequest(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } val coreServerVersion = normalizeServerVersion(request.serverVersion) request.installedAppVersion?.let { version -> @@ -86,13 +94,13 @@ class SignedAppStoreContractAcquirer( } val compatibleCatalogUrl = "${appStoreBaseUrl.trimEnd('/')}/platform/$coreServerVersion/apps.json" - val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId) + val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId, catalogPolicy) val platformReleases = platformCatalog.releases val exactPlatformRelease = request.installedAppVersion?.let { installed -> platformReleases.firstOrNull { release -> release.version == installed } } val allCatalog = request.installedAppVersion?.takeIf { exactPlatformRelease == null }?.let { - fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId) + fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId, catalogPolicy) } val exactRelease = exactPlatformRelease ?: allCatalog?.releases?.firstOrNull { release -> release.version == request.installedAppVersion } @@ -417,15 +425,13 @@ class SignedAppStoreContractAcquirer( private fun fetchCatalogReleases( catalogUrl: String, appId: String, + catalogPolicy: CatalogPolicy, ): CatalogReleases { requireSecureUrl(catalogUrl) - catalogCache.load(catalogUrl)?.let { cached -> - return parseReleases( - cached.decodeToString(), - appId, - catalogUrl, - requireOfficialGitHubHost = requireHttps, - ) + // A refresh only bypasses the cached copy; the copy stays until a readable catalog replaces it. + val bypassCache = catalogPolicy == CatalogPolicy.Refresh && catalogRefreshThrottle.tryRefresh(catalogUrl) + catalogCache.load(catalogUrl)?.takeUnless { bypassCache }?.let { cached -> + return parseReleases(cached.decodeToString(), appId, catalogUrl, requireOfficialGitHubHost = requireHttps) } val response = execute(Request.Builder().url(catalogUrl).get().build()) val bytes = response.use { catalogResponse -> diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt new file mode 100644 index 000000000..58a16e832 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt @@ -0,0 +1,131 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class CatalogRefreshTest { + private var now = 1_000_000L + + @Test + fun `an explicit retry after no matching release downloads the catalog again and finds the new release`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + assertEquals(unavailable(ContractUnavailableReason.NoMatchingRelease), acquirer.acquireOutcome(request())) + assertEquals(2, server.requestCount) + + server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) + server.enqueue(MockResponse(body = "package")) + val refreshed = acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) + assertEquals("4.0.2", assertIs(refreshed).contract.contractVersion) + assertEquals(4, server.requestCount) + } + } + + @Test + fun `automatic discovery keeps using the cached catalog`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + repeat(2) { acquirer.acquireOutcome(request()) } + assertEquals(2, server.requestCount) + } + } + + @Test + fun `a failed or unreadable forced download keeps the cached catalog`() { + for (failed in listOf(MockResponse(code = 503, body = "unavailable"), MockResponse(body = "[truncated"))) { + MockWebServer().use { server -> + server.start() + val cache = MemoryAppStoreCatalogCache() + val acquirer = acquirer(server, cache) + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + acquirer.acquireOutcome(request()) + val cached = catalogUrls(server).map { url -> cache.load(url)?.decodeToString() } + + server.enqueue(failed) + // Each catalog URL has its own refresh window, so the full catalog is refreshed too. + if (failed.code != 503) server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) + if (failed.code == 503) { + assertFailsWith { acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) } + } else { + // The unreadable platform catalog is not cached and makes the source malformed. + assertEquals( + unavailable(ContractUnavailableReason.MalformedSource), + acquirer.acquireOutcome(request(), CatalogPolicy.Refresh), + ) + } + assertEquals(cached, catalogUrls(server).map { url -> cache.load(url)?.decodeToString() }) + assertEquals(if (failed.code == 503) 3 else 4, server.requestCount) + } + } + } + + @Test + fun `repeated retries download each catalog at most once per interval`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + acquirer.acquireOutcome(request()) + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + repeat(5) { acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) } + assertEquals(4, server.requestCount) + + now += CatalogRefreshThrottle.DEFAULT_CATALOG_REFRESH_INTERVAL_MILLIS + repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } + acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) + assertEquals(6, server.requestCount) + } + } + + @Test + fun `the throttle opens one window per catalog and counts clock skew as a new window`() { + val throttle = CatalogRefreshThrottle(intervalMillis = 10, nowMillis = { now }) + assertTrue(throttle.tryRefresh("a")) + assertFalse(throttle.tryRefresh("a")) + assertTrue(throttle.tryRefresh("b")) + now += 9 + assertFalse(throttle.tryRefresh("a")) + now += 1 + assertTrue(throttle.tryRefresh("a")) + now -= 100 + assertTrue(throttle.tryRefresh("a")) + } + + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun request() = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun catalogUrls(server: MockWebServer) = + listOf(server.url("api/v1/platform/34.0.3/apps.json").toString(), server.url("api/v1/apps.json").toString()) + + private fun catalog(server: MockWebServer, version: String): String { + val download = server.url("downloads/cospend-$version.tar.gz") + return """[{"id":"cospend","certificate":"certificate","releases":[""" + + """{"version":"$version","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""" + } + + private fun acquirer(server: MockWebServer, cache: AppStoreCatalogCache = MemoryAppStoreCatalogCache()) = + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }, + catalogCache = cache, + catalogRefreshThrottle = CatalogRefreshThrottle(nowMillis = { now }), + ) +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 7917a5ab3..bb626da7d 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,4 +1,4 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4100 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4092 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1721 @@ -23,7 +23,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11019 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1550 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1712 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 @@ -47,7 +47,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|876 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6137 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6128 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1069 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 09610b013..3d2aa995b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -38,6 +38,34 @@ enum class AppStoreContractUnavailableReason { MalformedSource, } +/** Whether App Store acquisition may reuse its cached catalog. Only an explicit user retry refreshes it. */ +enum class AppStoreCatalogPolicy { UseCache, RefreshCatalog } + +/** What discovery asks of [NextcloudPlatformServices.acquireSignedOpenApiContract]. */ +data class AppStoreContractRequest( + val appId: String, + val serverVersion: String, + val installedAppVersion: String?, + val catalogPolicy: AppStoreCatalogPolicy = AppStoreCatalogPolicy.UseCache, +) + +/** Discovery services whose App Store requests use [policy]; every other member delegates unchanged. */ +internal fun NextcloudPlatformServices.withAppStoreCatalogPolicy(policy: AppStoreCatalogPolicy): NextcloudPlatformServices { + if (policy == AppStoreCatalogPolicy.UseCache) return this + val delegate = this + return object : NextcloudPlatformServices by delegate { + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = + delegate.acquireSignedOpenApiContract(request.copy(catalogPolicy = policy)) + } +} + +/** + * The first discovery of an app screen uses the cached catalog. A discovery started by, or after, an + * explicit retry on that screen asks for a fresh catalog; the platform acquirer bounds how often. + */ +internal fun NextcloudPlatformServices.forDiscoveryAttempt(attempt: Int): NextcloudPlatformServices = + withAppStoreCatalogPolicy(if (attempt > 0) AppStoreCatalogPolicy.RefreshCatalog else AppStoreCatalogPolicy.UseCache) + /** Result of [NextcloudPlatformServices.acquireSignedOpenApiContract]. Failures are thrown instead. */ sealed interface AppStoreContractAcquisition { data class Acquired(val contract: AcquiredOpenApiContract) : AppStoreContractAcquisition diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 250cc1711..1fbc0702e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -158,7 +158,7 @@ suspend fun discoverDynamicAppDescriptor( ) val acquisition = observeDynamicDiscoveryStage(DynamicDiscoveryStage.PackageAcquisition, services::recordSupportDiagnostic, available = { it is AppStoreContractAcquisition.Acquired }) { - services.acquireSignedOpenApiContract(contractAppId, coreVersion, installedVersion) + services.acquireSignedOpenApiContract(AppStoreContractRequest(contractAppId, coreVersion, installedVersion)) }.getOrElse { failure -> // Only the typed kind enters diagnostics and copy; exception text and class names stay local. val kind = failure.appStoreContractFailureKind() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 290f3e238..6c9e0c352 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -2184,7 +2184,7 @@ private fun AppInfoScreen( discoveryError = null runCatching { discoverDynamicAppDescriptor( - services = services, + services = services.forDiscoveryAttempt(discoveryAttempt), session = session, app = app, serverVersion = serverVersion, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 35f0a3814..a4886b95f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1439,11 +1439,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa error("People mutation transport is unavailable on this platform.") } - suspend fun acquireSignedOpenApiContract( - appId: String, - serverVersion: String, - installedAppVersion: String?, - ): AppStoreContractAcquisition + suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition suspend fun listActivities(session: NextcloudSession, limit: Int = DEFAULT_ACTIVITY_LIMIT): List diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 431678398..f15f98e02 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1,6 +1,5 @@ package dev.obiente.nextcloudnative.app -import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache @@ -5031,16 +5030,8 @@ class DesktopNextcloudServices( NextcloudApiResponse(response.status, response.body, response.contentType, response.etag, response.location) } - override suspend fun acquireSignedOpenApiContract( - appId: String, - serverVersion: String, - installedAppVersion: String?, - ): AppStoreContractAcquisition = withContext(Dispatchers.IO) { - acquireAppStoreContract( - ContractAcquisitionRequest(appId, serverVersion, installedAppVersion), - contractAcquirer::acquireOutcome, - ) - } + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer::acquireOutcome) } override suspend fun listActivities(session: NextcloudSession, limit: Int): List = withContext(Dispatchers.IO) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt new file mode 100644 index 000000000..6bb391eec --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt @@ -0,0 +1,60 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class AppStoreCatalogRetryTest { + private val session = NextcloudSession("https://fixture.invalid", "fixture", "synthetic") + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + @Test + fun theFirstDiscoveryUsesTheCachedCatalogAndAnExplicitRetryRefreshesIt() = runBlocking { + for ((attempt, expected) in listOf( + 0 to AppStoreCatalogPolicy.UseCache, + 1 to AppStoreCatalogPolicy.RefreshCatalog, + 3 to AppStoreCatalogPolicy.RefreshCatalog, + )) { + val requests = mutableListOf() + discoverDynamicAppDescriptor( + services = services(requests).forDiscoveryAttempt(attempt), + session = session, + app = app, + serverVersion = "34.0.3", + installedAppVersionHint = "1.0.0", + ) + assertEquals( + listOf(AppStoreContractRequest("example", "34.0.3", "1.0.0", expected)), + requests, + "attempt $attempt", + ) + } + } + + @Test + fun theCachedPolicyKeepsTheOriginalServices() { + val services = services(mutableListOf()) + assertSame(services, services.forDiscoveryAttempt(0)) + assertSame(services, services.withAppStoreCatalogPolicy(AppStoreCatalogPolicy.UseCache)) + } + + private fun services(requests: MutableList): NextcloudPlatformServices { + val unavailable = Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { _, method, _ -> error("Unexpected ${method.name}") } as NextcloudPlatformServices + return object : NextcloudPlatformServices by unavailable { + override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = Unit + + override suspend fun executeNextcloudApi(session: NextcloudSession, request: NextcloudApiRequest) = + NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) + + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition { + requests += request + return AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoMatchingRelease) + } + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index 1e1746a28..f7c7c3a35 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -157,11 +157,8 @@ class DynamicContractFallbackDiscoveryTest { override suspend fun executeNextcloudApi(session: NextcloudSession, request: NextcloudApiRequest) = NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) - override suspend fun acquireSignedOpenApiContract( - appId: String, - serverVersion: String, - installedAppVersion: String?, - ): AppStoreContractAcquisition = acquire() + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = + acquire() } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt index c8c09379a..75e2f0453 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -182,11 +182,8 @@ class DynamicServerProbeFallbackTest { override suspend fun executeNextcloudApi(session: NextcloudSession, request: NextcloudApiRequest) = execute(request) - override suspend fun acquireSignedOpenApiContract( - appId: String, - serverVersion: String, - installedAppVersion: String?, - ): AppStoreContractAcquisition = acquire() + override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = + acquire() } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index 9d8f2a2da..824ef2ce0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.contracts.CatalogPolicy import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason @@ -18,7 +19,7 @@ import kotlin.test.assertFalse import kotlin.test.assertSame class JvmAppStoreContractAcquisitionTest { - private val request = ContractAcquisitionRequest("example", "34.0.3", "1.0.0") + private val request = AppStoreContractRequest("example", "34.0.3", "1.0.0") @Test fun verifiedContractKeepsItsProvenanceAndKind() { @@ -36,7 +37,7 @@ class JvmAppStoreContractAcquisitionTest { VerifiedContractKind.VerifiedReadRoutes to AcquiredContractKind.VerifiedReadRoutes, VerifiedContractKind.OpenApiWithVerifiedReadRoutes to AcquiredContractKind.OpenApiWithVerifiedReadRoutes, )) { - val outcome = acquireAppStoreContract(request) { + val outcome = acquireAppStoreContract(request) { _, _ -> ContractAcquisitionOutcome.Acquired(verified(source, kind)) } val acquired = (outcome as AppStoreContractAcquisition.Acquired).contract @@ -56,7 +57,7 @@ class JvmAppStoreContractAcquisitionTest { )) { assertEquals( AppStoreContractAcquisition.Unavailable(expected), - acquireAppStoreContract(request) { ContractAcquisitionOutcome.Unavailable(reason) }, + acquireAppStoreContract(request) { _, _ -> ContractAcquisitionOutcome.Unavailable(reason) }, ) } } @@ -71,7 +72,7 @@ class JvmAppStoreContractAcquisitionTest { IllegalStateException("synthetic") to AppStoreContractFailureKind.Unexpected, )) { val typed = assertFailsWith { - acquireAppStoreContract(request) { throw failure } + acquireAppStoreContract(request) { _, _ -> throw failure } } assertEquals(expected, typed.kind) assertSame(failure, typed.cause) @@ -85,7 +86,7 @@ class JvmAppStoreContractAcquisitionTest { ExceptionInInitializerError(IllegalArgumentException("Syntax error near index 3")), )) { val typed = assertFailsWith { - acquireAppStoreContract(request) { throw failure } + acquireAppStoreContract(request) { _, _ -> throw failure } } assertEquals(AppStoreContractFailureKind.RuntimeIncompatible, typed.kind) assertSame(failure, typed.cause) @@ -96,9 +97,24 @@ class JvmAppStoreContractAcquisitionTest { @Test fun cancellationAndUnrelatedErrorsAreNotConverted() { val cancelled = CancellationException("stop") - assertSame(cancelled, assertFailsWith { acquireAppStoreContract(request) { throw cancelled } }) + assertSame(cancelled, assertFailsWith { acquireAppStoreContract(request) { _, _ -> throw cancelled } }) val overflow = StackOverflowError() - assertSame(overflow, assertFailsWith { acquireAppStoreContract(request) { throw overflow } }) + assertSame(overflow, assertFailsWith { acquireAppStoreContract(request) { _, _ -> throw overflow } }) + } + + @Test + fun theRequestAndItsCatalogPolicyCrossTheBoundaryUnchanged() { + for ((policy, expected) in listOf( + AppStoreCatalogPolicy.UseCache to CatalogPolicy.UseCache, + AppStoreCatalogPolicy.RefreshCatalog to CatalogPolicy.Refresh, + )) { + var seen: Pair? = null + acquireAppStoreContract(request.copy(catalogPolicy = policy)) { contractRequest, catalogPolicy -> + seen = contractRequest to catalogPolicy + ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoMatchingRelease) + } + assertEquals(ContractAcquisitionRequest("example", "34.0.3", "1.0.0") to expected, seen) + } } private fun verified(source: OpenApiContractSourceKind, kind: VerifiedContractKind) = VerifiedOpenApiContract( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index d7153e52b..307a2f510 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.contracts.CatalogPolicy import dev.obiente.nextcloudnative.contracts.ContractAcquisitionFailureKind import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest @@ -19,11 +20,15 @@ import kotlinx.coroutines.CancellationException * its message. Other errors, such as running out of memory, still propagate. */ fun acquireAppStoreContract( - request: ContractAcquisitionRequest, - acquire: (ContractAcquisitionRequest) -> ContractAcquisitionOutcome, + request: AppStoreContractRequest, + acquire: (ContractAcquisitionRequest, CatalogPolicy) -> ContractAcquisitionOutcome, ): AppStoreContractAcquisition { + val catalogPolicy = when (request.catalogPolicy) { + AppStoreCatalogPolicy.UseCache -> CatalogPolicy.UseCache + AppStoreCatalogPolicy.RefreshCatalog -> CatalogPolicy.Refresh + } val outcome = try { - acquire(request) + acquire(ContractAcquisitionRequest(request.appId, request.serverVersion, request.installedAppVersion), catalogPolicy) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { From 1a3e1fd9ab05599a0d33789366268b7b53405ae8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 03:20:20 +0200 Subject: [PATCH 21/49] fix: keep recorded server probe reasons when the server version is missing When no App Store release can be selected because the server version is unavailable, discovery now keeps a recorded server probe reason, such as sign-in, permission, inconclusive, or unsupported contract, with the same precedence as an App Store no-contract outcome. Only without a recorded server reason does the card report the unconfirmed version. --- DYNAMIC_APP_DESCRIPTOR.md | 7 ++++--- tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicContractFallback.kt | 10 ++++++++++ .../app/DynamicNativeRuntime.kt | 5 +---- .../app/DynamicServerProbeFallbackTest.kt | 18 +++++++++++++++++- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index c34f5ea2b..4434a985c 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -253,9 +253,10 @@ wins among equals: A 404, another definitive status, a non-JSON response at a static contract location, or an empty or non-advertising OCS API Viewer catalog is absence and -records nothing. When the App Store then completes without a contract, any -recorded server result takes precedence over every App Store no-contract -reason. A definitive server absence keeps the App Store reason. Diagnostics +records nothing. When the App Store then completes without a contract, or no +release can be selected because the server version is unavailable, any recorded +server result takes precedence over the App Store or version reason. A +definitive server absence keeps the App Store or version reason. Diagnostics record both sources either way, and never include request URLs, headers, or response bodies. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index bb626da7d..ead847c9b 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -8,7 +8,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|11 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|864 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1801 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2229 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2226 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2515 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 3d2aa995b..2330855b7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -155,6 +155,16 @@ internal fun DynamicDescriptorDiscovery.withUnavailable( "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", ) +/** + * Keeps the metadata fallback when no App Store release can be selected without a server version. + * A recorded server probe result still explains the outcome better, with the same precedence as + * [withUnavailable]; only otherwise does the card report the unconfirmed version. + */ +internal fun DynamicDescriptorDiscovery.withVersionUnavailable(): DynamicDescriptorDiscovery = withFallback( + fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: DynamicContractFallbackReason.VersionUnavailable, + "The server version is unavailable, so an App Store release could not be selected safely.", +) + private val SERVER_PROBE_FALLBACK_REASONS = setOf( DynamicContractFallbackReason.ServerAuthenticationRequired, DynamicContractFallbackReason.ServerPermissionDenied, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 1fbc0702e..2cc9a8d9b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -132,10 +132,7 @@ suspend fun discoverDynamicAppDescriptor( onProgress = onProgress, ) if (sameOrigin.acquisition != DynamicDescriptorAcquisition.MetadataFallback) return sameOrigin - val coreVersion = serverVersion?.coreVersionOrNull() ?: return sameOrigin.withFallback( - DynamicContractFallbackReason.VersionUnavailable, - "The server version is unavailable, so an App Store release could not be selected safely.", - ) + val coreVersion = serverVersion?.coreVersionOrNull() ?: return sameOrigin.withVersionUnavailable() val contractAppId = app.canonicalAppStoreId() onProgress( DynamicDescriptorDiscoveryProgress( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt index 75e2f0453..8c1d56fdb 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -58,6 +58,21 @@ class DynamicServerProbeFallbackTest { } } + @Test + fun aMissingServerVersionKeepsARecordedServerReason() = runBlocking { + for (probe in ServerProbe.entries) { + var acquisitions = 0 + val discovery = discover(probe, serverVersion = null) { + acquisitions += 1 + AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) + } + assertEquals(probe.reason ?: DynamicContractFallbackReason.VersionUnavailable, discovery.fallbackReason, probe.name) + assertEquals(0, acquisitions, probe.name) + assertTrue(discovery.diagnostics.any { "server version is unavailable" in it }, probe.name) + assertEquals(if (probe.failure == null) 0 else 1, discovery.diagnostics.count { "server API probe" in it }, probe.name) + } + } + @Test fun sameOriginDiscoveryRecordsTheTypedProbeResultForEachScenario() = runBlocking { for (probe in ServerProbe.entries) { @@ -158,12 +173,13 @@ class DynamicServerProbeFallbackTest { private suspend fun discover( probe: ServerProbe, + serverVersion: String? = "34.0.3", acquire: suspend () -> AppStoreContractAcquisition, ): DynamicDescriptorDiscovery = discoverDynamicAppDescriptor( services = services(server(probe), acquire), session = session, app = app, - serverVersion = "34.0.3", + serverVersion = serverVersion, installedAppVersionHint = "1.0.0", ) From c74fdba124b01bae5a7cb4be05cd02684d7d2ca2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 03:34:04 +0200 Subject: [PATCH 22/49] fix: treat unreadable OCS API Viewer spec lists as inconclusive An API Viewer entry for the app whose spec list is missing, not an array, or has a malformed identifier with no usable one left is now an unreadable response instead of an empty advertisement. Usable identifiers beside malformed ones are still read, and a well-formed empty list or one naming only other apps remains absence. --- DYNAMIC_APP_DESCRIPTOR.md | 8 ++- .../app/DynamicSameOriginDiscovery.kt | 14 +++-- .../app/DynamicViewerSpecListTest.kt | 59 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicViewerSpecListTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 4434a985c..bb7eea5b3 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -246,13 +246,17 @@ wins among equals: may be limited to administrators. 3. Inconclusive results that a retry may resolve: no response, a 5xx status, HTTP 429, or a successful catalog or contract response that is not readable - JSON or declares no OpenAPI version (`UnreadableResponse`). + JSON or declares no OpenAPI version (`UnreadableResponse`). An OCS API + Viewer entry for the app whose spec list is missing, not an array, or has a + malformed identifier with no usable one left is also `UnreadableResponse`; + usable identifiers beside malformed ones are still read. 4. `UnsupportedContract`: a JSON contract that declares a version other than OpenAPI 3, such as Swagger 2, or an OpenAPI 3 contract that cannot be compiled. A 404, another definitive status, a non-JSON response at a static contract -location, or an empty or non-advertising OCS API Viewer catalog is absence and +location, an empty or non-advertising OCS API Viewer catalog, or a well-formed +spec list that is empty or names only other apps is absence and records nothing. When the App Store then completes without a contract, or no release can be selected because the server version is unavailable, any recorded server result takes precedence over the App Store or version reason. A diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index 34eb9b0d8..af1895ca3 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -254,13 +254,19 @@ private suspend fun discoverOfficialViewerSpecIds( diagnostics += "OCS API Viewer does not advertise an OpenAPI specification for $appId." return emptyList() } - val specs = (appEntry["specs"] as? JsonArray) - .orEmpty() - .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } - .filter { it.matches(Regex("[A-Za-z0-9_.-]+")) && (it == appId || it.startsWith("$appId-")) } + // A missing or non-array list, or a malformed identifier when nothing usable remains, is an + // unreadable advertisement. A well-formed empty list, or one naming only other apps, is absence. + val advertised = appEntry["specs"] as? JsonArray + val identifiers = advertised.orEmpty().map { (it as? JsonPrimitive)?.takeIf { value -> value.isString }?.contentOrNull } + val wellFormed = identifiers.filterNotNull().filter { it.matches(Regex("[A-Za-z0-9_.-]+")) } + val specs = wellFormed + .filter { it == appId || it.startsWith("$appId-") } .distinct() .sortedWith(compareBy { it != appId }.thenBy { it }) if (specs.isEmpty()) { + if (advertised == null || wellFormed.size != identifiers.size) { + probe.record(DynamicServerProbeFailure.UnreadableResponse) + } diagnostics += "OCS API Viewer advertised $appId without a usable specification ID." } return specs diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicViewerSpecListTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicViewerSpecListTest.kt new file mode 100644 index 000000000..3b65264ab --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicViewerSpecListTest.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class DynamicViewerSpecListTest { + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + @Test + fun anUnreadableAdvertisedSpecListIsInconclusive() = runBlocking { + for (specs in listOf(null, "\"example\"", "{}", """["../escape", 5, null, "bad id!"]""", """["other", 7]""")) { + val (discovery, requested) = discover(specs) + assertEquals(DynamicContractFallbackReason.ServerProbeInconclusive, discovery.fallbackReason, specs) + assertEquals( + listOf("The server API probe was inconclusive: UnreadableResponse."), + discovery.diagnostics.filter { "server API probe" in it }, + specs, + ) + assertEquals(emptyList(), requested.filter { it.startsWith("$CATALOG/") }, specs) + } + } + + @Test + fun wellFormedIdentifiersAreStillUsedBesideMalformedOnes() = runBlocking { + val (discovery, requested) = discover("""["bad id!", "example", 42, "example-public"]""") + assertEquals(listOf("$CATALOG/example", "$CATALOG/example-public"), requested.filter { it.startsWith("$CATALOG/") }) + // Both advertised specs answered 404, which is definitive, so nothing inconclusive was recorded. + assertEquals(null, discovery.fallbackReason) + } + + @Test + fun aGenuinelyEmptyOrForeignSpecListIsAbsence() = runBlocking { + for (specs in listOf("[]", """["other", "other-public"]""")) { + val (discovery, requested) = discover(specs) + assertEquals(null, discovery.fallbackReason, specs) + assertEquals(emptyList(), discovery.diagnostics.filter { "server API probe" in it }, specs) + assertEquals(emptyList(), requested.filter { it.startsWith("$CATALOG/") }, specs) + } + } + + private suspend fun discover(specs: String?): Pair> { + val requested = mutableListOf() + val entry = if (specs == null) """{"id":"example"}""" else """{"id":"example","specs":$specs}""" + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> + requested += request.relativePath + if (request.relativePath == CATALOG) { + NextcloudApiResponse(200, "[$entry]".encodeToByteArray(), "application/json", etag = null) + } else { + NextcloudApiResponse(404, ByteArray(0), "application/json", etag = null) + } + }) + return discovery to requested + } + + private companion object { + const val CATALOG = "/index.php/apps/ocs_api_viewer/apps" + } +} From ed99b5409e54867c161327ffd5975319ffdf9301 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:19:48 +0000 Subject: [PATCH 23/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index c319b658e..fd01fea24 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,7 +585,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "4c3d6810c51d344c70bb1113248c345f19ac387f557c93e62ef0578f1e83d1f1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "f3fc3ebc44104f303fd9a9436fb577f77eea198729f2ad72e1239b9dce88bf6d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "79bb347d92be621079008d3ab4fcde43167fe9d37e182e6762e100f3ad2d1215", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", @@ -596,11 +596,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "f3167838346590009337e2d20097515c400b8939e9fa01176eb7391735a2f315", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "4144e635162d2a3757fb946e954e3e8a833732797925db4ce6b49487a71c8359", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "0d316600af61ef82cac3cf862a81334fc889d183284906705dc02b39d60ad1df", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "bb7bcd1b8c71f7de8d5f999d46bad554bd88554fcb9a05a22b8c1f3df4ae1895", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", @@ -752,12 +752,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "ff7b04865462f28448deff0230f6c817019e95b9f8b95c9cae81d324694c2afc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "c7e2f2d01fcf2291c01c48109c28b45860b7e5966c14c2de88370b6cd18e10c1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "cdc4452719adbda03cbe96d646bb095df8e91471d5d3b1986b2a5131d77974f1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "e195b74b027dc506892e05b0746471c94c6bf204b046bc79a1c3cb1aca900509", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "70b83f289d3b5dbe4b6494b62827bb35b3f228066eeaf514092c0f9a6bfa9de1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5076b1ad4aa9368f6c64dca000dea383120886bc518ee061df36916677d30298", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "8f556f89320fea91eb5bca8bfe8fdddc675ea19fecef10cd067b6e05f461eca5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "0d1605e5c2cfc43ef45d0ed4c20a7260d09b254efe1231bd21c65c36884ae69f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "df00a758c8a29e9d1b221911a03f31547dc3510816da6228f8d38c607d0f4092", From d990b033195f253c304ad86c36fc1cf0c7c67a4c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 05:13:03 +0200 Subject: [PATCH 24/49] fix: classify every contract source failure and keep server reasons on all paths - An explicit catalog retry denied by the refresh window no longer falls through to a request when nothing is cached; it reports a recently checked outcome, so retries make at most one App Store request per catalog URL per window. Automatic discovery still downloads a missing catalog. - Declared, streamed, and archive size-limit violations throw a typed source exception and are reported as unusable source data instead of an unexpected or verification failure. Oversized linked resources are skipped like other unusable linked data. - Malformed or misencoded app metadata in a linked source archive yields no candidate. - A catalog download value that is not an HTTP URL is a malformed entry. - Every App Store path without a usable contract, including typed and unexpected acquisition failures and unusable acquired contracts, now applies the recorded server probe reason through one precedence point. --- DYNAMIC_APP_DESCRIPTOR.md | 51 ++++- .../contracts/AppStoreCatalogReleases.kt | 6 +- .../contracts/ContractAcquisitionFailure.kt | 12 ++ .../contracts/ContractAcquisitionModels.kt | 8 + .../contracts/ContractAcquisitionOutcome.kt | 6 + .../contracts/LinkedSourceArchive.kt | 12 +- .../SignedAppStoreContractAcquirer.kt | 34 +-- .../contracts/SignedPackageTarReader.kt | 17 +- .../contracts/ContractSourceLimitsTest.kt | 198 ++++++++++++++++++ .../contracts/MalformedCatalogReleaseTest.kt | 10 + .../app/DynamicContractFallback.kt | 59 ++++-- .../DynamicContractFallbackDiscoveryTest.kt | 2 + .../app/DynamicServerProbeFallbackTest.kt | 32 +++ .../app/JvmAppStoreContractAcquisitionTest.kt | 3 + .../app/JvmAppStoreContractAcquisition.kt | 2 + 15 files changed, 405 insertions(+), 47 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index bb7eea5b3..b9f70d364 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -191,6 +191,10 @@ The fallback card derives its title, explanation, and action from that reason: digest; - the app's linked source refused or rate limited the last fallback request (HTTP 403 or 429), so a contract may still exist; +- an explicit retry came less than a minute after the previous catalog check + and no saved catalog could be reused; +- the App Store returned data this version cannot use, such as a response over + its size limit; - the server's own API catalog or contract probe was inconclusive; - the server rejected the account's credentials, or the account may not read the server's API catalog, during that probe; @@ -231,9 +235,13 @@ App Store catalogs are cached for six hours. The first discovery of an app screen uses the cache. A discovery started by, or after, an explicit retry on that screen carries `AppStoreCatalogPolicy.RefreshCatalog` through the platform boundary to the acquirer, which downloads each catalog again at most once per -minute per catalog URL; retries inside that window reuse the cached copy. A -forced download that fails or is unreadable leaves the cached catalog in place, -because only a readable catalog replaces it. +minute per catalog URL. A retry inside that window reuses the cached copy; if +none is available, for example after a cold start or an expired entry, it makes +no request and reports `RecentlyChecked`. Explicit retries therefore cause at +most one App Store request per catalog URL per window. Automatic discovery +without a cached catalog downloads it normally. A forced download that fails or +is unreadable leaves the cached catalog in place, because only a readable +catalog replaces it. [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. @@ -257,13 +265,44 @@ wins among equals: A 404, another definitive status, a non-JSON response at a static contract location, an empty or non-advertising OCS API Viewer catalog, or a well-formed spec list that is empty or names only other apps is absence and -records nothing. When the App Store then completes without a contract, or no -release can be selected because the server version is unavailable, any recorded -server result takes precedence over the App Store or version reason. A +records nothing. Every App Store path that ends without a usable contract goes +through one precedence point: no release can be selected because the server +version is unavailable, no release or contract exists, acquisition fails with a +typed or unexpected failure, or an acquired contract cannot be parsed or +compiled. Any recorded server result takes precedence over that reason. A definitive server absence keeps the App Store or version reason. Diagnostics record both sources either way, and never include request URLs, headers, or response bodies. +### Failure classification + +Every failure caused by remote or source data maps to a typed source outcome, +every network failure maps to a network or temporarily unavailable outcome, and +only genuine client faults remain unexpected. Size limits throw +`ContractSourceTooLargeException`, a source failure. + +| Site | Failure | Classification | +| --- | --- | --- | +| Request validation | Malformed app ID, server version, or installed version | Invalid request, shown as an unconfirmed version | +| Verified contract cache | Missing, expired, or unreadable entry | Ignored; acquisition continues | +| Catalog and package requests | No response; non-success status; redirect away from HTTPS | Network; source unavailable; verification failed | +| Catalog and package bodies | Declared or streamed size over the limit | Source unusable | +| Catalog document | Truncated, empty, not JSON, or not an array | Malformed source, never cached | +| Catalog entry | Missing value, or a download that is not an HTTP URL | Malformed source when it would be selected | +| Catalog cache write | Local storage failure | Ignored | +| Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | +| Package verification | Signature, certificate, revocation, identity, archive structure, or app metadata rejected | Verification failed | +| Package archive limits | Entry, expanded, path, or metadata-count limit exceeded | Source unusable | +| Package OpenAPI JSON or YAML | Unreadable or not OpenAPI 3 | Candidate skipped | +| Linked metadata, tree, spec, and schema requests | 404; size over the limit; other status; no response | Skipped; skipped; source unavailable; network | +| Linked archive request | HTTP 403 or 429 | Source temporarily unavailable | +| Linked archive content | Corrupt, unsafe path, identity mismatch, malformed or misencoded `info.xml`, size limit | No candidate | +| Linked JSON or YAML documents | Unreadable | Skipped | +| Acquired contract | Unreadable JSON or a descriptor that cannot be compiled | Unsupported contract | +| Contract module class loading | `LinkageError` | Client fault | +| Same-origin probes | See the precedence list above | Typed probe result | +| Remaining unexpected failures | XML parser configuration, internal invariants | Client fault | + ## Read failures [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 5b04dffdb..5dfeed677 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.contracts import java.net.URI +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -25,6 +26,8 @@ internal data class MalformedCatalogEntry(val version: String?, val field: Malfo internal data class CatalogReleases( val releases: List, val malformed: List = emptyList(), + /** True when an explicit retry was refused by the refresh window and nothing cached was read. */ + val refreshDenied: Boolean = false, ) { /** False when the whole document was unreadable; such a document must not be cached. */ val documentReadable: Boolean get() = malformed.none { entry -> entry.field == MalformedCatalogField.Document } @@ -101,7 +104,8 @@ internal fun parseReleases( .filter { release -> !release.optBoolean("isNightly", false) } .mapNotNull { release -> val version = release.optString("version").takeIf(String::isNotBlank) - val download = release.optString("download").takeIf(String::isNotBlank) + // A download value that is not a valid HTTP URL is malformed catalog data, like a missing one. + val download = release.optString("download").takeIf { value -> value.toHttpUrlOrNull() != null } val signature = release.optString("signature").takeIf(String::isNotBlank) val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) if (version == null || certificate == null || download == null || signature == null || digest == null) { diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt index 9fa63bf73..114e8b83c 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt @@ -24,6 +24,9 @@ enum class ContractAcquisitionFailureKind { /** The server-reported app ID or version cannot select a release safely. */ InvalidRequest, + /** A source returned data that cannot be used, such as a response over its size limit. */ + SourceUnusable, + /** This runtime could not load or initialize acquisition code. */ RuntimeIncompatible, @@ -34,6 +37,12 @@ enum class ContractAcquisitionFailureKind { /** A contract source answered, but not with the requested resource. */ class ContractSourceHttpException(val status: Int, message: String) : IllegalStateException(message) +/** + * A response or archive exceeded a declared, streamed, or expanded size or count limit. The remote + * source, not the client, is at fault. + */ +class ContractSourceTooLargeException(val limit: Long, message: String) : IllegalStateException(message) + /** A package or source failed verification. The original failure stays available as the cause. */ class ContractSourceVerificationException(message: String, cause: Throwable? = null) : SecurityException(message, cause) @@ -50,6 +59,7 @@ class ContractAcquisitionRequestException(message: String) : IllegalArgumentExce */ fun classifyContractAcquisitionFailure(failure: Throwable): ContractAcquisitionFailureKind = when (failure) { is ContractSourceHttpException -> ContractAcquisitionFailureKind.SourceUnavailable + is ContractSourceTooLargeException -> ContractAcquisitionFailureKind.SourceUnusable is ContractSourceVerificationException -> ContractAcquisitionFailureKind.VerificationFailed is ContractAcquisitionRequestException -> ContractAcquisitionFailureKind.InvalidRequest is IOException -> ContractAcquisitionFailureKind.Network @@ -76,6 +86,8 @@ internal inline fun requireSuccessfulSource(response: Response, message: () -> S */ internal fun Exception.asSourceVerificationFailure(appId: String): RuntimeException = when (this) { is CancellationException -> this + // An oversized package is unusable source data rather than a failed signature or identity check. + is ContractSourceTooLargeException -> this is ContractSourceVerificationException -> this else -> ContractSourceVerificationException("The signed $appId package was rejected.", this) } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt index 8ad4b6a95..9560a565d 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionModels.kt @@ -63,3 +63,11 @@ data class VerifiedPackageContract( val document: String, val contractKind: VerifiedContractKind = VerifiedContractKind.OpenApi, ) + +/** Byte limits for remote contract sources. Production uses the defaults; tests may lower them. */ +data class ContractResponseLimits( + val catalogBytes: Long = 32L * 1024L * 1024L, + val archiveBytes: Long = 64L * 1024L * 1024L, + val appInfoBytes: Long = 512L * 1024L, + val githubTreeBytes: Long = 8L * 1024L * 1024L, +) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt index eee362d80..f8d846ca1 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -19,6 +19,12 @@ enum class ContractUnavailableReason { * such as its certificate, download, signature, or signature digest, so it cannot be verified. */ MalformedSource, + + /** + * An explicit retry asked for a fresh catalog inside its refresh window, and no cached copy was + * available to reuse, so no App Store request was made. See [CatalogRefreshThrottle]. + */ + RecentlyChecked, } /** The typed result of [SignedAppStoreContractAcquirer.acquireOutcome]. Failures are thrown instead. */ diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt index 969336d62..dc0bb6a41 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/LinkedSourceArchive.kt @@ -2,13 +2,15 @@ package dev.obiente.nextcloudnative.contracts import java.io.IOException import java.util.concurrent.CancellationException +import org.xml.sax.SAXException /** * Reads a contract from an App Store-linked source archive, which is a lower-trust fallback. * - * A truncated or corrupt archive, an unsafe entry path, or an archive whose app identity does not - * match the release is unusable source data. It yields no candidate, exactly like an archive - * without a contract, instead of escaping as an unexpected client failure. Cancellation propagates. + * A truncated or corrupt archive, malformed app metadata XML, an unsafe entry path, or an archive + * whose app identity does not match the release is unusable source data. It yields no candidate, + * exactly like an archive without a contract, instead of escaping as an unexpected client failure. + * Cancellation and genuine runtime failures propagate. */ internal fun extractLinkedSourceContractOrNull( archive: ByteArray, @@ -23,5 +25,9 @@ internal fun extractLinkedSourceContractOrNull( } catch (_: IllegalArgumentException) { null } catch (_: IOException) { + // Truncated archives and unsupported XML encodings. + null +} catch (_: SAXException) { + // Malformed appinfo/info.xml. null } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 29bca15e8..5d5eba3ae 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -28,6 +28,7 @@ class SignedAppStoreContractAcquirer( private val githubApiBaseUrl: String = OFFICIAL_GITHUB_API_BASE_URL, private val codeloadGitHubBaseUrl: String = OFFICIAL_CODELOAD_GITHUB_BASE_URL, private val catalogRefreshThrottle: CatalogRefreshThrottle = CatalogRefreshThrottle(), + private val responseLimits: ContractResponseLimits = ContractResponseLimits(), ) { private val verifiedContracts = ConcurrentHashMap() private val inFlightAcquisitions = @@ -95,6 +96,7 @@ class SignedAppStoreContractAcquirer( val compatibleCatalogUrl = "${appStoreBaseUrl.trimEnd('/')}/platform/$coreServerVersion/apps.json" val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId, catalogPolicy) + if (platformCatalog.refreshDenied) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) val platformReleases = platformCatalog.releases val exactPlatformRelease = request.installedAppVersion?.let { installed -> platformReleases.firstOrNull { release -> release.version == installed } @@ -102,6 +104,7 @@ class SignedAppStoreContractAcquirer( val allCatalog = request.installedAppVersion?.takeIf { exactPlatformRelease == null }?.let { fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId, catalogPolicy) } + if (allCatalog?.refreshDenied == true) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) val exactRelease = exactPlatformRelease ?: allCatalog?.releases?.firstOrNull { release -> release.version == request.installedAppVersion } val compatibleReleases = compatibleReleaseCandidates( @@ -123,7 +126,7 @@ class SignedAppStoreContractAcquirer( requireSuccessfulSource(response) { "Downloading the signed ${request.appId} package failed (HTTP ${response.code})." } - response.readBodyLimited(MAX_ARCHIVE_BYTES) + response.readBodyLimited(responseLimits.archiveBytes) } ?: continue val verified = try { trustVerifier.verifyAndExtract(release, archive) @@ -201,7 +204,7 @@ class SignedAppStoreContractAcquirer( val infoXml = fetchOptionalGitHubResource( url = infoUrl, expectedBaseUrl = rawGitHubBaseUrl, - maximumBytes = MAX_APP_INFO_BYTES, + maximumBytes = responseLimits.appInfoBytes, )?.decodeToString() ?: return@sourceLoop val identity = runCatching { parseAppInfoIdentity(infoXml) }.getOrNull() ?: return@sourceLoop if (identity.first != release.appId || identity.second != release.version) return@sourceLoop @@ -211,7 +214,7 @@ class SignedAppStoreContractAcquirer( val treeBytes = fetchOptionalGitHubResource( url = treeUrl, expectedBaseUrl = githubApiBaseUrl, - maximumBytes = MAX_GITHUB_TREE_BYTES, + maximumBytes = responseLimits.githubTreeBytes, accept = "application/vnd.github+json", recoverableStatusCodes = setOf(403, 429), ) @@ -275,7 +278,7 @@ class SignedAppStoreContractAcquirer( val archive = fetchOptionalGitHubResource( url = archiveUrl, expectedBaseUrl = codeloadGitHubBaseUrl, - maximumBytes = MAX_ARCHIVE_BYTES, + maximumBytes = responseLimits.archiveBytes, recoverableStatusCodes = setOf(403, 429), // The archive is the last exact-tag fallback; a refusal here leaves the outcome unknown. onRecoverableStatus = attempt::markSourceTemporarilyUnavailable, @@ -418,7 +421,12 @@ class SignedAppStoreContractAcquirer( } requireSecureResponse(githubResponse) requireExpectedOrigin(githubResponse, expectedBaseUrl) - githubResponse.readBodyLimited(maximumBytes) + // An oversized linked resource is unusable, like a missing one; the lower-trust fallback moves on. + try { + githubResponse.readBodyLimited(maximumBytes) + } catch (_: ContractSourceTooLargeException) { + null + } } } @@ -429,17 +437,19 @@ class SignedAppStoreContractAcquirer( ): CatalogReleases { requireSecureUrl(catalogUrl) // A refresh only bypasses the cached copy; the copy stays until a readable catalog replaces it. - val bypassCache = catalogPolicy == CatalogPolicy.Refresh && catalogRefreshThrottle.tryRefresh(catalogUrl) - catalogCache.load(catalogUrl)?.takeUnless { bypassCache }?.let { cached -> + val refreshAllowed = catalogPolicy == CatalogPolicy.Refresh && catalogRefreshThrottle.tryRefresh(catalogUrl) + catalogCache.load(catalogUrl)?.takeUnless { refreshAllowed }?.let { cached -> return parseReleases(cached.decodeToString(), appId, catalogUrl, requireOfficialGitHubHost = requireHttps) } + // A retry inside the refresh window never reaches the App Store, even without a cached copy. + if (catalogPolicy == CatalogPolicy.Refresh && !refreshAllowed) return CatalogReleases(emptyList(), refreshDenied = true) val response = execute(Request.Builder().url(catalogUrl).get().build()) val bytes = response.use { catalogResponse -> requireSuccessfulSource(catalogResponse) { "Loading the Nextcloud App Store catalog failed (HTTP ${catalogResponse.code})." } requireSecureResponse(catalogResponse) - catalogResponse.readBodyLimited(MAX_CATALOG_BYTES) + catalogResponse.readBodyLimited(responseLimits.catalogBytes) } // An unreadable document is not cached, so checking again downloads the catalog again. return parseReleases(bytes.decodeToString(), appId, catalogUrl, requireOfficialGitHubHost = requireHttps) @@ -471,10 +481,6 @@ class SignedAppStoreContractAcquirer( const val OFFICIAL_RAW_GITHUB_BASE_URL = "https://raw.githubusercontent.com" const val OFFICIAL_GITHUB_API_BASE_URL = "https://api.github.com" const val OFFICIAL_CODELOAD_GITHUB_BASE_URL = "https://codeload.github.com" - private const val MAX_CATALOG_BYTES = 32L * 1024L * 1024L - private const val MAX_ARCHIVE_BYTES = 64L * 1024L * 1024L - private const val MAX_APP_INFO_BYTES = 512L * 1024L - private const val MAX_GITHUB_TREE_BYTES = 8L * 1024L * 1024L private val APP_ID_PATTERN = Regex("[A-Za-z0-9_.-]+") private val APP_VERSION_PATTERN = Regex("[0-9A-Za-z][0-9A-Za-z.+_-]*") } @@ -654,7 +660,7 @@ internal fun parseAppInfoIdentity(infoXml: String): Pair { private fun Response.readBodyLimited(maximumBytes: Long): ByteArray { val responseBody = body val declaredLength = responseBody.contentLength() - check(declaredLength < 0 || declaredLength <= maximumBytes) { "The response is too large." } + if (declaredLength > maximumBytes) throw ContractSourceTooLargeException(maximumBytes, "The response is too large.") return responseBody.byteStream().use { input -> val output = ByteArrayOutputStream() val buffer = ByteArray(DEFAULT_BUFFER_SIZE) @@ -663,7 +669,7 @@ private fun Response.readBodyLimited(maximumBytes: Long): ByteArray { val read = input.read(buffer) if (read == -1) break total += read - check(total <= maximumBytes) { "The response is too large." } + if (total > maximumBytes) throw ContractSourceTooLargeException(maximumBytes, "The response is too large.") output.write(buffer, 0, read) } output.toByteArray() diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt index 6550d12a7..73eaf8007 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedPackageTarReader.kt @@ -16,12 +16,13 @@ internal fun readSelectedTarFiles(archive: ByteArray): Map { val prefix = header.tarText(345, 155) val headerName = if (prefix.isBlank()) name else "$prefix/$name" val size = header.tarOctal(124, 12) - check(size in 0..MAX_TAR_ENTRY_BYTES) { "An app package entry is too large." } + check(size >= 0) { "An app package entry has an invalid size." } + requireWithinLimit(size, MAX_TAR_ENTRY_BYTES) { "An app package entry is too large." } totalUncompressed += size - check(totalUncompressed <= MAX_TAR_UNCOMPRESSED_BYTES) { "The app package expands beyond the safety limit." } + requireWithinLimit(totalUncompressed, MAX_TAR_UNCOMPRESSED_BYTES) { "The app package expands beyond the safety limit." } val type = header[156].toInt().toChar() if (type == 'L') { - check(size <= MAX_TAR_PATH_BYTES) { "An app package path exceeds the safety limit." } + requireWithinLimit(size, MAX_TAR_PATH_BYTES) { "An app package path exceeds the safety limit." } pendingLongPath = decodeTarLongPath(input.readExactly(size.toInt())) val longPathPadding = (TAR_BLOCK_BYTES - (size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES input.skipExactly(longPathPadding) @@ -51,8 +52,10 @@ internal fun readSelectedTarFiles(archive: ByteArray): Map { ) if (keep) { val selectedFileLimit = if (staticMetadata) MAX_STATIC_METADATA_FILE_BYTES else MAX_SELECTED_FILE_BYTES - check(size <= selectedFileLimit) { "A contract metadata file exceeds the safety limit." } - check(selected.size < MAX_SELECTED_ARCHIVE_FILES) { "The app package has too many contract metadata files." } + requireWithinLimit(size, selectedFileLimit) { "A contract metadata file exceeds the safety limit." } + requireWithinLimit(selected.size + 1L, MAX_SELECTED_ARCHIVE_FILES.toLong()) { + "The app package has too many contract metadata files." + } selected[fullName] = input.readExactly(size.toInt()) } else { input.skipExactly(size) @@ -65,6 +68,10 @@ internal fun readSelectedTarFiles(archive: ByteArray): Map { return selected } +private inline fun requireWithinLimit(value: Long, limit: Long, message: () -> String) { + if (value > limit) throw ContractSourceTooLargeException(limit, message()) +} + private fun validateArchivePath(path: String) { require(path.isNotBlank() && !path.startsWith('/') && '\\' !in path) { "The app package contains an unsafe path." } require(path.split('/').none { segment -> segment == "." || segment == ".." }) { diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt new file mode 100644 index 000000000..ae6f925c4 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt @@ -0,0 +1,198 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class ContractSourceLimitsTest { + private var now = 10_000_000L + private val smallLimits = ContractResponseLimits(catalogBytes = 2_000, archiveBytes = 64, appInfoBytes = 512, githubTreeBytes = 32) + + @Test + fun `repeated retries on a cold start make exactly one catalog request per window`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + server.enqueue(MockResponse(body = "[unreadable")) + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + repeat(3) { + assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + } + assertEquals(1, server.requestCount) + } + } + + @Test + fun `an expired cached catalog is throttled like a cold start`() { + val directory = Files.createTempDirectory("catalog-cache").toFile() + try { + MockWebServer().use { server -> + server.start() + val cache = FileAppStoreCatalogCache(directory, nowMillis = { now }) + val acquirer = acquirer(server, cache) + server.enqueue(MockResponse(body = """[{"id":"other","certificate":"c","releases":[]}]""")) + assertEquals(unavailable(ContractUnavailableReason.NoMatchingRelease), acquirer.acquireOutcome(unversioned())) + now += 7L * 60L * 60L * 1_000L + server.enqueue(MockResponse(code = 503, body = "unavailable")) + assertFailsWith { acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh) } + repeat(2) { + assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + } + assertEquals(2, server.requestCount) + } + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `automatic discovery without a cached catalog still downloads it`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + repeat(3) { server.enqueue(MockResponse(body = "[unreadable")) } + repeat(3) { + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquirer.acquireOutcome(unversioned())) + } + assertEquals(3, server.requestCount) + } + } + + @Test + fun `an oversized catalog or package is unusable source data whether declared or streamed`() { + for (streamed in listOf(false, true)) { + MockWebServer().use { server -> + server.start() + server.enqueue(oversized(2_500, streamed)) + val failure = assertFailsWith { acquirer(server).acquireOutcome(unversioned()) } + assertEquals(ContractAcquisitionFailureKind.SourceUnusable, classifyContractAcquisitionFailure(failure)) + } + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) + server.enqueue(oversized(100, streamed)) + val failure = assertFailsWith { acquirer(server).acquireOutcome(versioned()) } + assertEquals(ContractAcquisitionFailureKind.SourceUnusable, classifyContractAcquisitionFailure(failure)) + } + } + } + + @Test + fun `an oversized package entry stays a size failure instead of a verification failure`() { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) + server.enqueue(MockResponse(body = "package")) + val tooLarge = ContractSourceTooLargeException(1, "An app package entry is too large.") + val failure = assertFailsWith { + acquirer(server, verify = { throw tooLarge }).acquireOutcome(versioned()) + } + assertSame(tooLarge, failure) + } + } + + @Test + fun `an oversized linked tree or archive is skipped like any unusable linked resource`() { + for (streamed in listOf(false, true)) { + for (oversizedArchive in listOf(false, true)) { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(server, "4.0.2", linked = true))) + server.enqueue(MockResponse(body = "signed-package-without-openapi")) + server.enqueue(MockResponse(body = "cospend4.0.2")) + if (oversizedArchive) { + server.enqueue(MockResponse(code = 404, body = "no tree")) + server.enqueue(oversized(100, streamed)) + } else { + server.enqueue(oversized(100, streamed)) + server.enqueue(MockResponse(code = 404, body = "no archive")) + } + val outcome = acquirer(server, verify = { throw OpenApiContractMissingException("synthetic") }) + .acquireOutcome(versioned()) + assertEquals(unavailable(ContractUnavailableReason.NoUsableContract), outcome) + assertEquals(5, server.requestCount) + } + } + } + } + + @Test + fun `malformed or mismatched linked app metadata yields no candidate`() { + val release = AppStoreRelease("cospend", "4.0.2", "https://fixture.invalid/c.tar.gz", "s", "sha512", "c", "u") + for (infoXml in listOf( + "cospend4.0.2", + "cospend4.0.2", + """cospend4.0.2""", + "cospend4.0.2", + )) { + assertNull(extractLinkedSourceContractOrNull(tarGz("app/appinfo/info.xml" to infoXml), release), infoXml) + } + assertNull(extractLinkedSourceContractOrNull(byteArrayOf(1, 2, 3), release)) + } + + private fun oversized(bytes: Int, streamed: Boolean): MockResponse { + val body = "x".repeat(bytes) + return if (streamed) MockResponse.Builder().chunkedBody(body, 16).build() else MockResponse(body = body) + } + + private fun tarGz(vararg files: Pair): ByteArray { + val tar = ByteArrayOutputStream() + files.forEach { (name, content) -> + val bytes = content.encodeToByteArray() + val header = ByteArray(512) + name.encodeToByteArray().copyInto(header, 0) + "%011o".format(bytes.size).encodeToByteArray().copyInto(header, 124) + header[156] = '0'.code.toByte() + tar.write(header) + tar.write(bytes) + tar.write(ByteArray((512 - bytes.size % 512) % 512)) + } + tar.write(ByteArray(1024)) + return ByteArrayOutputStream().also { output -> GZIPOutputStream(output).use { it.write(tar.toByteArray()) } } + .toByteArray() + } + + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun unversioned() = ContractAcquisitionRequest("cospend", "34.0.3", null) + + private fun versioned() = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun catalog(server: MockWebServer, version: String, linked: Boolean = false): String { + val download = if (linked) { + server.url("source-owner/source-repo/releases/download/v$version/cospend-$version.tar.gz") + } else { + server.url("downloads/cospend-$version.tar.gz") + } + return """[{"id":"cospend","website":"https://github.com/source-owner/source-repo",""" + + """"certificate":"c","releases":[{"version":"$version","download":"$download",""" + + """"signature":"c2lnbmF0dXJl","signatureDigest":"sha512","isNightly":false}]}]""" + } + + private fun acquirer( + server: MockWebServer, + cache: AppStoreCatalogCache = MemoryAppStoreCatalogCache(), + verify: (AppStoreRelease) -> VerifiedPackageContract = { error("No package may be verified.") }, + ) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + catalogCache = cache, + rawGitHubBaseUrl = server.url("raw").toString(), + githubApiBaseUrl = server.url("github-api").toString(), + codeloadGitHubBaseUrl = server.url("codeload").toString(), + catalogRefreshThrottle = CatalogRefreshThrottle(nowMillis = { now }), + responseLimits = smallLimits, + ) +} diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt index c192bd432..cb115c2c7 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt @@ -37,6 +37,16 @@ class MalformedCatalogReleaseTest { ) } + @Test + fun `a download value that is not an HTTP URL is malformed catalog data`() { + for (download in listOf("not a url", "ftp://fixture.invalid/c.tar.gz", "https://exa mple.invalid/c.tar.gz")) { + val catalog = catalog(release("4.0.2").put("download", download)) + val parsed = parseReleases(catalog.toString(), "cospend", "https://fixture.invalid/apps.json", false) + assertEquals(listOf(MalformedCatalogEntry("4.0.2", MalformedCatalogField.Download)), parsed.malformed, download) + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquireFrom(catalog), download) + } + } + @Test fun `a matching release missing any required value is a malformed source`() { for (field in listOf("download", "signature", "signatureDigest", "version", "certificate")) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 2330855b7..068c8a355 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -16,6 +16,9 @@ enum class AppStoreContractFailureKind { /** The app ID or version reported by the server cannot select a release safely. */ InvalidRequest, + /** A source returned data that cannot be used, such as a response over its size limit. */ + SourceUnusable, + /** Contract acquisition code could not load or initialize on this runtime. */ RuntimeIncompatible, @@ -36,6 +39,9 @@ enum class AppStoreContractUnavailableReason { /** The catalog lists the app, but the entry that would be selected lacks data needed to verify it. */ MalformedSource, + + /** An explicit retry fell inside the catalog refresh window and no cached catalog could be reused. */ + RecentlyChecked, } /** Whether App Store acquisition may reuse its cached catalog. Only an explicit user retry refreshes it. */ @@ -92,6 +98,8 @@ enum class DynamicContractFallbackReason { NoMatchingRelease, SourceTemporarilyUnavailable, MalformedSource, + RecentlyChecked, + SourceUnusable, /** The same-origin server probe was inconclusive and the App Store yielded no contract. */ ServerProbeInconclusive, @@ -114,6 +122,7 @@ internal fun AppStoreContractFailureKind.fallbackReason(): DynamicContractFallba AppStoreContractFailureKind.SourceUnavailable -> DynamicContractFallbackReason.AppStoreUnavailable AppStoreContractFailureKind.VerificationFailed -> DynamicContractFallbackReason.VerificationFailed AppStoreContractFailureKind.InvalidRequest -> DynamicContractFallbackReason.VersionUnavailable + AppStoreContractFailureKind.SourceUnusable -> DynamicContractFallbackReason.SourceUnusable AppStoreContractFailureKind.RuntimeIncompatible, AppStoreContractFailureKind.Unexpected, -> DynamicContractFallbackReason.ClientFault @@ -125,43 +134,45 @@ internal fun AppStoreContractUnavailableReason.fallbackReason(): DynamicContract DynamicContractFallbackReason.SourceTemporarilyUnavailable AppStoreContractUnavailableReason.NoUsableContract -> DynamicContractFallbackReason.NoVerifiedContract AppStoreContractUnavailableReason.MalformedSource -> DynamicContractFallbackReason.MalformedSource + AppStoreContractUnavailableReason.RecentlyChecked -> DynamicContractFallbackReason.RecentlyChecked } /** Classifies a failure from the platform acquisition boundary without reading exception text. */ internal fun Throwable.appStoreContractFailureKind(): AppStoreContractFailureKind = (this as? AppStoreContractAcquisitionException)?.kind ?: AppStoreContractFailureKind.Unexpected -/** Keeps the metadata fallback and records why no verified contract replaced it. */ +/** + * Keeps the metadata fallback and records why no verified contract replaced it. + * + * This is the single precedence point for every App Store path that ends without a usable contract: + * no release selected, no contract, a typed or unexpected acquisition failure, or an acquired contract + * that cannot be parsed or compiled. Any recorded same-origin probe result wins over the App Store + * reason, because the server may still offer a contract, or offers one that explains the outcome + * better. Among probe results, access problems come first, then inconclusive results, then an + * unsupported contract (see [DynamicServerProbeFailure]). A definitive server absence leaves the App + * Store reason in charge. Diagnostics always keep both sources. + */ internal fun DynamicDescriptorDiscovery.withFallback( reason: DynamicContractFallbackReason, diagnostic: String, -): DynamicDescriptorDiscovery = copy(diagnostics = diagnostics + diagnostic, fallbackReason = reason) +): DynamicDescriptorDiscovery = copy( + diagnostics = diagnostics + diagnostic, + fallbackReason = fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: reason, +) -/** - * Keeps the metadata fallback when the App Store boundary completed without a contract. - * - * Precedence: any same-origin probe result wins over every App Store no-contract reason, because - * the server may still offer a contract, or offers one that explains the outcome better. Among probe - * results, access problems come first, then inconclusive results, then an unsupported contract - * (see [DynamicServerProbeFailure]). A definitive server absence leaves the App Store reason in - * charge. Diagnostics keep both sources. - */ +/** Keeps the metadata fallback when the App Store boundary completed without a contract. */ internal fun DynamicDescriptorDiscovery.withUnavailable( reason: AppStoreContractUnavailableReason, appId: String, coreVersion: String, ): DynamicDescriptorDiscovery = withFallback( - fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: reason.fallbackReason(), + reason.fallbackReason(), "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", ) -/** - * Keeps the metadata fallback when no App Store release can be selected without a server version. - * A recorded server probe result still explains the outcome better, with the same precedence as - * [withUnavailable]; only otherwise does the card report the unconfirmed version. - */ +/** Keeps the metadata fallback when no App Store release can be selected without a server version. */ internal fun DynamicDescriptorDiscovery.withVersionUnavailable(): DynamicDescriptorDiscovery = withFallback( - fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: DynamicContractFallbackReason.VersionUnavailable, + DynamicContractFallbackReason.VersionUnavailable, "The server version is unavailable, so an App Store release could not be selected safely.", ) @@ -230,6 +241,18 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "to administrators, and the App Store offered no verified contract. Only app details are shown.", "Check again", ) + DynamicContractFallbackReason.RecentlyChecked -> DynamicContractFallbackNotice( + "Checked moments ago", + "nati.ve checked the App Store for this app less than a minute ago and has no saved catalog to " + + "reuse. Wait a minute, then try again.", + "Try again", + ) + DynamicContractFallbackReason.SourceUnusable -> DynamicContractFallbackNotice( + "App Store data unusable", + "The Nextcloud App Store returned data this version cannot use, such as a response over its size " + + "limit, so no native view is offered. Only app details are shown.", + "Check again", + ) DynamicContractFallbackReason.VersionUnavailable -> DynamicContractFallbackNotice( "App version not confirmed", "The server or app version could not be confirmed, so no matching signed App Store release " + diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index f7c7c3a35..c3539bbb8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -24,6 +24,7 @@ class DynamicContractFallbackDiscoveryTest { AppStoreContractFailureKind.InvalidRequest to DynamicContractFallbackReason.VersionUnavailable, AppStoreContractFailureKind.RuntimeIncompatible to DynamicContractFallbackReason.ClientFault, AppStoreContractFailureKind.Unexpected to DynamicContractFallbackReason.ClientFault, + AppStoreContractFailureKind.SourceUnusable to DynamicContractFallbackReason.SourceUnusable, ) assertEquals(AppStoreContractFailureKind.entries.toSet(), expected.keys) for ((kind, reason) in expected) { @@ -54,6 +55,7 @@ class DynamicContractFallbackDiscoveryTest { DynamicContractFallbackReason.SourceTemporarilyUnavailable, AppStoreContractUnavailableReason.NoUsableContract to DynamicContractFallbackReason.NoVerifiedContract, AppStoreContractUnavailableReason.MalformedSource to DynamicContractFallbackReason.MalformedSource, + AppStoreContractUnavailableReason.RecentlyChecked to DynamicContractFallbackReason.RecentlyChecked, ) assertEquals(AppStoreContractUnavailableReason.entries.toSet(), expected.keys) for ((unavailable, reason) in expected) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt index 8c1d56fdb..f2c009704 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -58,6 +58,27 @@ class DynamicServerProbeFallbackTest { } } + @Test + fun aTypedAcquisitionFailureKeepsARecordedServerReasonAndBothDiagnostics() = runBlocking { + for (probe in ServerProbe.entries) { + for (kind in AppStoreContractFailureKind.entries) { + val discovery = discover(probe) { throw AppStoreContractAcquisitionException(kind) } + val label = "$probe x $kind" + assertEquals(probe.reason ?: kind.fallbackReason(), discovery.fallbackReason, label) + assertTrue("App Store contract acquisition failed: ${kind.name}." in discovery.diagnostics, label) + assertEquals(if (probe.failure == null) 0 else 1, discovery.diagnostics.count { "server API probe" in it }, label) + } + } + } + + @Test + fun anAcquiredButUnusableContractKeepsARecordedServerReason() = runBlocking { + for (probe in ServerProbe.entries) { + val discovery = discover(probe) { AppStoreContractAcquisition.Acquired(unreadableContract()) } + assertEquals(probe.reason ?: DynamicContractFallbackReason.UnsupportedContract, discovery.fallbackReason, probe.name) + } + } + @Test fun aMissingServerVersionKeepsARecordedServerReason() = runBlocking { for (probe in ServerProbe.entries) { @@ -155,6 +176,17 @@ class DynamicServerProbeFallbackTest { }) } + private fun unreadableContract() = AcquiredOpenApiContract( + appId = "example", + appVersion = "1.0.0", + contractVersion = "1.0.0", + specFile = "openapi.json", + document = "not json", + packageUrl = "https://fixture.invalid/example.tar.gz", + sourceUrl = "https://fixture.invalid/example.tar.gz#openapi.json", + sourceKind = AcquiredOpenApiContractSourceKind.SignedAppPackage, + ) + private fun server(probe: ServerProbe): suspend (NextcloudApiRequest) -> NextcloudApiResponse = { request -> val viewer = request.relativePath.contains("ocs_api_viewer") when (probe) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index 824ef2ce0..dacde58c6 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequestException import dev.obiente.nextcloudnative.contracts.ContractSourceHttpException +import dev.obiente.nextcloudnative.contracts.ContractSourceTooLargeException import dev.obiente.nextcloudnative.contracts.ContractSourceVerificationException import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.VerifiedContractKind @@ -54,6 +55,7 @@ class JvmAppStoreContractAcquisitionTest { AppStoreContractUnavailableReason.SourceTemporarilyUnavailable, ContractUnavailableReason.NoUsableContract to AppStoreContractUnavailableReason.NoUsableContract, ContractUnavailableReason.MalformedSource to AppStoreContractUnavailableReason.MalformedSource, + ContractUnavailableReason.RecentlyChecked to AppStoreContractUnavailableReason.RecentlyChecked, )) { assertEquals( AppStoreContractAcquisition.Unavailable(expected), @@ -69,6 +71,7 @@ class JvmAppStoreContractAcquisitionTest { ContractSourceHttpException(503, "synthetic") to AppStoreContractFailureKind.SourceUnavailable, ContractSourceVerificationException("synthetic") to AppStoreContractFailureKind.VerificationFailed, ContractAcquisitionRequestException("synthetic") to AppStoreContractFailureKind.InvalidRequest, + ContractSourceTooLargeException(1, "synthetic") to AppStoreContractFailureKind.SourceUnusable, IllegalStateException("synthetic") to AppStoreContractFailureKind.Unexpected, )) { val typed = assertFailsWith { diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index 307a2f510..2b5764cdc 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -48,6 +48,7 @@ private fun ContractUnavailableReason.toAppStoreReason(): AppStoreContractUnavai AppStoreContractUnavailableReason.SourceTemporarilyUnavailable ContractUnavailableReason.NoUsableContract -> AppStoreContractUnavailableReason.NoUsableContract ContractUnavailableReason.MalformedSource -> AppStoreContractUnavailableReason.MalformedSource + ContractUnavailableReason.RecentlyChecked -> AppStoreContractUnavailableReason.RecentlyChecked } private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFailureKind = when (this) { @@ -55,6 +56,7 @@ private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFai ContractAcquisitionFailureKind.SourceUnavailable -> AppStoreContractFailureKind.SourceUnavailable ContractAcquisitionFailureKind.VerificationFailed -> AppStoreContractFailureKind.VerificationFailed ContractAcquisitionFailureKind.InvalidRequest -> AppStoreContractFailureKind.InvalidRequest + ContractAcquisitionFailureKind.SourceUnusable -> AppStoreContractFailureKind.SourceUnusable ContractAcquisitionFailureKind.RuntimeIncompatible -> AppStoreContractFailureKind.RuntimeIncompatible ContractAcquisitionFailureKind.Unexpected -> AppStoreContractFailureKind.Unexpected } From 6714e5dba079ba452febb65f9db92c56a34a9457 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:55:37 +0000 Subject: [PATCH 25/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index fd01fea24..824a89c6d 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,7 +585,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "f3fc3ebc44104f303fd9a9436fb577f77eea198729f2ad72e1239b9dce88bf6d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "b6569bba81ee19a8987b458b10bbd7ae972c9d7e3346707ced08247515c79743", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "79bb347d92be621079008d3ab4fcde43167fe9d37e182e6762e100f3ad2d1215", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", From dd90eb8bab88ff6f1cd52ca1c35164d65fc2fa69 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 06:23:22 +0200 Subject: [PATCH 26/49] fix(contracts): keep local verifier faults out of package verification failures The package verification boundary now catches only the expected validation failure types: security, state and argument checks, archive and encoding I/O, XML, and JSON failures. A missing bundled trust resource throws a dedicated local exception that is classified as a client fault, and parser configuration faults also propagate, so the UI no longer presents a local packaging defect as a package that failed verification. --- DYNAMIC_APP_DESCRIPTOR.md | 1 + .../contracts/ContractAcquisitionFailure.kt | 7 ++ .../NextcloudAppPackageTrustVerifier.kt | 4 +- .../SignedAppStoreContractAcquirer.kt | 21 +++++- .../contracts/PackageVerificationFaultTest.kt | 71 +++++++++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/PackageVerificationFaultTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index b9f70d364..cf0176832 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -292,6 +292,7 @@ only genuine client faults remain unexpected. Size limits throw | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | | Package verification | Signature, certificate, revocation, identity, archive structure, or app metadata rejected | Verification failed | +| Package verification | Bundled trust resource missing | Client fault (runtime incompatible); retrying cannot repair it | | Package archive limits | Entry, expanded, path, or metadata-count limit exceeded | Source unusable | | Package OpenAPI JSON or YAML | Unreadable or not OpenAPI 3 | Candidate skipped | | Linked metadata, tree, spec, and schema requests | 404; size over the limit; other status; no response | Skipped; skipped; source unavailable; network | diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt index 114e8b83c..5ca7d5677 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt @@ -43,6 +43,12 @@ class ContractSourceHttpException(val status: Int, message: String) : IllegalSta */ class ContractSourceTooLargeException(val limit: Long, message: String) : IllegalStateException(message) +/** + * A trust resource bundled with the app is missing. This is a local packaging fault: retrying the + * package check cannot repair it, so it is never reported as a source verification failure. + */ +class ContractTrustResourceMissingException(message: String) : RuntimeException(message) + /** A package or source failed verification. The original failure stays available as the cause. */ class ContractSourceVerificationException(message: String, cause: Throwable? = null) : SecurityException(message, cause) @@ -65,6 +71,7 @@ fun classifyContractAcquisitionFailure(failure: Throwable): ContractAcquisitionF is IOException -> ContractAcquisitionFailureKind.Network is GeneralSecurityException -> ContractAcquisitionFailureKind.VerificationFailed is LinkageError -> ContractAcquisitionFailureKind.RuntimeIncompatible + is ContractTrustResourceMissingException -> ContractAcquisitionFailureKind.RuntimeIncompatible else -> ContractAcquisitionFailureKind.Unexpected } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt index f508ff538..914e73604 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt @@ -76,9 +76,9 @@ private fun X509Certificate.subjectCommonName(): String? { return Regex("(?:^|,)CN=([^,]+)").find(principal)?.groupValues?.get(1) } -private fun loadBundledTrustResource(name: String): ByteArray = +internal fun loadBundledTrustResource(name: String): ByteArray = NextcloudAppPackageTrustVerifier::class.java.classLoader.getResourceAsStream(name)?.use(InputStream::readBytes) - ?: error("The bundled Nextcloud trust resource is missing: $name") + ?: throw ContractTrustResourceMissingException("The bundled Nextcloud trust resource is missing: $name") private const val ROOT_CERTIFICATES_RESOURCE = "nextcloud-codesigning/root.crt" private const val ROOT_REVOCATION_LIST_RESOURCE = "nextcloud-codesigning/root.crl" diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 5d5eba3ae..7ffd3dcbb 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -2,7 +2,9 @@ package dev.obiente.nextcloudnative.contracts import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.net.URI +import java.security.GeneralSecurityException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException @@ -11,6 +13,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import org.w3c.dom.Element import org.xml.sax.EntityResolver @@ -133,9 +136,25 @@ class SignedAppStoreContractAcquirer( } catch (_: OpenApiContractMissingException) { missingPackageContracts += release continue - } catch (failure: Exception) { + } catch (failure: GeneralSecurityException) { + // Signature, certificate, chain, and revocation failures. + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: SecurityException) { + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: IllegalStateException) { + // Identity, digest, and archive structure checks. Size limits and cancellation pass through. + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: IllegalArgumentException) { + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: IOException) { + // A truncated or corrupt archive, or an unsupported metadata encoding. + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: SAXException) { + throw failure.asSourceVerificationFailure(request.appId) + } catch (failure: JSONException) { throw failure.asSourceVerificationFailure(request.appId) } + // A missing bundled trust resource or parser configuration fault is local and propagates. if (verified.contractKind == VerifiedContractKind.VerifiedReadRoutes) { acquireGitHubTagContract( release = release, diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/PackageVerificationFaultTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/PackageVerificationFaultTest.kt new file mode 100644 index 000000000..af4bdddbe --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/PackageVerificationFaultTest.kt @@ -0,0 +1,71 @@ +package dev.obiente.nextcloudnative.contracts + +import java.security.SignatureException +import javax.xml.parsers.ParserConfigurationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class PackageVerificationFaultTest { + @Test + fun `a missing bundled trust resource is a local client fault`() { + val missing = assertFailsWith { + loadBundledTrustResource("nextcloud-codesigning/synthetic-missing.crt") + } + assertEquals(ContractAcquisitionFailureKind.RuntimeIncompatible, classifyContractAcquisitionFailure(missing)) + + val verifier = NextcloudAppPackageTrustVerifier(trustResourceLoader = { name -> + throw ContractTrustResourceMissingException("The bundled Nextcloud trust resource is missing: $name") + }) + val failure = assertFailsWith { acquireWith(verifier) } + assertEquals(ContractAcquisitionFailureKind.RuntimeIncompatible, classifyContractAcquisitionFailure(failure)) + } + + @Test + fun `a local parser configuration fault is not reported as a verification failure`() { + val configuration = ParserConfigurationException("synthetic") + val failure = assertFailsWith { + acquireWith(object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = throw configuration + }) + } + assertSame(configuration, failure) + assertEquals(ContractAcquisitionFailureKind.Unexpected, classifyContractAcquisitionFailure(failure)) + } + + @Test + fun `genuine certificate and signature failures remain verification failures`() { + // The bundled trust store loads, and the synthetic catalog certificate is not a certificate. + val certificate = assertFailsWith { acquireWith(NextcloudAppPackageTrustVerifier()) } + assertEquals(ContractAcquisitionFailureKind.VerificationFailed, classifyContractAcquisitionFailure(certificate)) + + val signature = SignatureException("synthetic") + val signed = assertFailsWith { + acquireWith(object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = throw signature + }) + } + assertSame(signature, signed.cause) + assertIs(signed) + } + + private fun acquireWith(verifier: AppPackageTrustVerifier): ContractAcquisitionOutcome = MockWebServer().use { server -> + server.start() + val download = server.url("downloads/cospend-4.0.2.tar.gz") + server.enqueue(MockResponse(body = """[{"id":"cospend","certificate":"certificate","releases":[""" + + """{"version":"4.0.2","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""")) + server.enqueue(MockResponse(body = "package")) + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = verifier, + ).acquireOutcome(ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2")) + } +} From 622f9efc582a3eedef3a5c8aee45df529fa8496a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 06:35:25 +0200 Subject: [PATCH 27/49] fix: carry typed diagnostic codes instead of matching diagnostic prose Contract information no longer derives stable diagnostic codes by matching message text. Discovery records typed codes alongside each diagnostic, server probe codes come from the typed probe result, and import codes derive from the typed acquisition kind, so rewording a message cannot change or drop a code. --- DYNAMIC_APP_DESCRIPTOR.md | 5 ++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicContractFallback.kt | 15 +++++ .../app/DynamicContractInfo.kt | 54 +++++++++++------- .../app/DynamicDiscoveryPersistence.kt | 1 + .../app/DynamicNativeRuntime.kt | 11 ++-- .../app/DynamicSameOriginDiscovery.kt | 21 ++++++- .../app/DynamicContractInfoTest.kt | 55 +++++++++++++++++-- 8 files changed, 130 insertions(+), 34 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index cf0176832..4049b2c17 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -177,6 +177,11 @@ or request URL. Cancellation propagates without a failure event, and a recorder failure never discards a usable contract. An unverified app version keeps writes disabled without showing a permanent refresh spinner. +The contract information dialog shows stable diagnostic codes, such as +`server-probe-inconclusive` or `signed-package-imported`. Discovery records +them as typed `DynamicDiagnosticCode` events, and import codes derive from the +typed acquisition kind, so rewording a diagnostic message never changes a code. + ## Metadata fallback reasons When no same-origin or App Store source yields a verified contract, discovery diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index ead847c9b..1c4a2f700 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -8,7 +8,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|11 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|864 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1801 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2226 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2225 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2515 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 068c8a355..c7e4027e1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -155,11 +155,25 @@ internal fun Throwable.appStoreContractFailureKind(): AppStoreContractFailureKin internal fun DynamicDescriptorDiscovery.withFallback( reason: DynamicContractFallbackReason, diagnostic: String, + code: DynamicDiagnosticCode? = null, ): DynamicDescriptorDiscovery = copy( diagnostics = diagnostics + diagnostic, + diagnosticCodes = diagnosticCodes + listOfNotNull(code), fallbackReason = fallbackReason.takeIf { it in SERVER_PROBE_FALLBACK_REASONS } ?: reason, ) +/** Keeps the metadata fallback after a typed or unexpected App Store acquisition failure. */ +internal fun DynamicDescriptorDiscovery.withAcquisitionFailure(kind: AppStoreContractFailureKind): DynamicDescriptorDiscovery = + withFallback( + kind.fallbackReason(), + "App Store contract acquisition failed: ${kind.name}.", + DynamicDiagnosticCode.AppStoreAcquisitionFailed, + ) + +/** Keeps the metadata fallback when an acquired contract cannot be parsed or compiled. */ +internal fun DynamicDescriptorDiscovery.withUnusableAcquiredContract(diagnostic: String): DynamicDescriptorDiscovery = + withFallback(DynamicContractFallbackReason.UnsupportedContract, diagnostic, DynamicDiagnosticCode.ContractInvalid) + /** Keeps the metadata fallback when the App Store boundary completed without a contract. */ internal fun DynamicDescriptorDiscovery.withUnavailable( reason: AppStoreContractUnavailableReason, @@ -168,6 +182,7 @@ internal fun DynamicDescriptorDiscovery.withUnavailable( ): DynamicDescriptorDiscovery = withFallback( reason.fallbackReason(), "No App Store contract for $appId on Nextcloud $coreVersion: ${reason.name}. Only app metadata is available.", + DynamicDiagnosticCode.MetadataOnly, ) /** Keeps the metadata fallback when no App Store release can be selected without a server version. */ diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt index d9c76ebd0..d00063ce3 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt @@ -4,6 +4,7 @@ import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor import dev.obiente.nextcloudnative.nativeui.model.DynamicChildCandidateStatus import dev.obiente.nextcloudnative.nativeui.model.DynamicResourceRecordContext import dev.obiente.nextcloudnative.nativeui.model.explainDynamicChildNavigation +import kotlinx.serialization.Serializable data class DynamicContractInfo( val acquisition: String, @@ -31,7 +32,8 @@ fun DynamicDescriptorDiscovery.toContractInfo( acquisition = acquisition.safeLabel(), appVersion = descriptor.app.version.safeVersion(), sourceSpecFile = sourcePath.safeSpecFilename(), - diagnosticCodes = diagnostics.mapNotNull(String::safeDiagnosticCode).distinct(), + // Codes come from typed discovery events and the acquisition kind, never from diagnostic prose. + diagnosticCodes = (diagnosticCodes + acquisition.importDiagnosticCodes()).map(DynamicDiagnosticCode::code).distinct(), resourceIds = descriptor.resources.map { it.id.safeContractId() }, layoutIds = descriptor.layouts.map { it.id.safeContractId() }, linkIds = descriptor.links.map { it.id.safeContractId() }, @@ -81,25 +83,37 @@ private fun String.safeContractId(): String { return safe.takeIf(String::isNotBlank) ?: "Unavailable" } -private fun String.safeDiagnosticCode(): String? = when { - contains("viewer", ignoreCase = true) && contains("not installed", ignoreCase = true) -> - "api-viewer-unavailable" - contains("static OpenAPI", ignoreCase = true) -> "static-contract-unavailable" - contains("App Store contract acquisition failed", ignoreCase = true) -> "app-store-acquisition-failed" - contains("server API probe was inconclusive", ignoreCase = true) -> "server-probe-inconclusive" - contains("server API probe was not authorized", ignoreCase = true) -> "server-probe-access-denied" - contains("server API probe found an unsupported contract", ignoreCase = true) -> "server-contract-unsupported" - contains("derived", ignoreCase = true) && contains("read-only endpoints", ignoreCase = true) -> - "verified-static-read-routes" - contains("verified read", ignoreCase = true) && contains("fallback routes", ignoreCase = true) -> - "verified-read-fallbacks" - contains("signed App Store package", ignoreCase = true) -> "signed-package-imported" - contains("GitHub release tag", ignoreCase = true) -> "linked-source-tag-imported" - contains("invalid OpenAPI", ignoreCase = true) || contains("could not be compiled", ignoreCase = true) -> - "contract-invalid" - contains("metadata-only", ignoreCase = true) || - contains("Only app metadata is available", ignoreCase = true) -> "metadata-only" - else -> null +/** Stable support codes for discovery events. The code strings are a public diagnostic contract. */ +@Serializable +enum class DynamicDiagnosticCode(val code: String) { + ApiViewerUnavailable("api-viewer-unavailable"), + StaticContractUnavailable("static-contract-unavailable"), + AppStoreAcquisitionFailed("app-store-acquisition-failed"), + ServerProbeInconclusive("server-probe-inconclusive"), + ServerProbeAccessDenied("server-probe-access-denied"), + ServerContractUnsupported("server-contract-unsupported"), + VerifiedStaticReadRoutes("verified-static-read-routes"), + VerifiedReadFallbacks("verified-read-fallbacks"), + SignedPackageImported("signed-package-imported"), + LinkedSourceTagImported("linked-source-tag-imported"), + ContractInvalid("contract-invalid"), + MetadataOnly("metadata-only"), +} + +/** The import a verified App Store contract records, derived from its typed acquisition kind. */ +internal fun DynamicDescriptorAcquisition.importDiagnosticCodes(): List = when (this) { + DynamicDescriptorAcquisition.SignedAppStorePackage -> listOf(DynamicDiagnosticCode.SignedPackageImported) + DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag -> listOf(DynamicDiagnosticCode.LinkedSourceTagImported) + DynamicDescriptorAcquisition.SignedAppStoreStaticRoutes, + DynamicDescriptorAcquisition.AppStoreLinkedStaticRoutes, + -> listOf(DynamicDiagnosticCode.VerifiedStaticReadRoutes) + DynamicDescriptorAcquisition.SignedAppStoreMergedContract, + DynamicDescriptorAcquisition.AppStoreLinkedMergedContract, + -> listOf(DynamicDiagnosticCode.VerifiedReadFallbacks) + DynamicDescriptorAcquisition.OcsApiViewer, + DynamicDescriptorAcquisition.StaticAppAsset, + DynamicDescriptorAcquisition.MetadataFallback, + -> emptyList() } internal fun DynamicContractInfo.countSummary(): String = diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt index 25aab757a..1ad3ff4e3 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt @@ -30,6 +30,7 @@ fun encodePersistedDynamicDiscovery(discovery: DynamicDescriptorDiscovery): Stri ), ), diagnostics = emptyList(), + diagnosticCodes = emptyList(), ) val encoded = persistedDynamicDiscoveryJson.encodeToString(bounded) return encoded.takeIf { value -> value.encodeToByteArray().size <= MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 2cc9a8d9b..6bcbbe72a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -69,6 +69,7 @@ data class DynamicDescriptorDiscovery( val sourcePath: String?, val acquisition: DynamicDescriptorAcquisition, val diagnostics: List = emptyList(), + val diagnosticCodes: List = emptyList(), val versionStatus: DynamicContractVersionStatus = DynamicContractVersionStatus.VerifiedCurrent, val fallbackReason: DynamicContractFallbackReason? = null, ) @@ -158,8 +159,7 @@ suspend fun discoverDynamicAppDescriptor( services.acquireSignedOpenApiContract(AppStoreContractRequest(contractAppId, coreVersion, installedVersion)) }.getOrElse { failure -> // Only the typed kind enters diagnostics and copy; exception text and class names stay local. - val kind = failure.appStoreContractFailureKind() - return sameOrigin.withFallback(kind.fallbackReason(), "App Store contract acquisition failed: ${kind.name}.") + return sameOrigin.withAcquisitionFailure(failure.appStoreContractFailureKind()) } val acquired = when (acquisition) { is AppStoreContractAcquisition.Acquired -> acquisition.contract @@ -168,8 +168,7 @@ suspend fun discoverDynamicAppDescriptor( val versionStatus = acquired.effectiveDynamicContractVersionStatus(observedVersionStatus) val document = observeDynamicDiscoveryStage(DynamicDiscoveryStage.ContractParsing, services::recordSupportDiagnostic) { dynamicJson.parseToJsonElement(acquired.document) as? JsonObject - }.getOrNull() ?: return sameOrigin.withFallback( - DynamicContractFallbackReason.UnsupportedContract, + }.getOrNull() ?: return sameOrigin.withUnusableAcquiredContract( "The acquired App Store contract contained invalid JSON.", ) val verifiedReadRouteCount = (document["x-nextcloud-native-verified-read-route-count"] as? JsonPrimitive) @@ -216,8 +215,7 @@ suspend fun discoverDynamicAppDescriptor( ), ).requireValid() }.getOrElse { - return sameOrigin.withFallback( - DynamicContractFallbackReason.UnsupportedContract, + return sameOrigin.withUnusableAcquiredContract( "The acquired App Store contract could not be compiled into a native read surface.", ) } @@ -241,6 +239,7 @@ suspend fun discoverDynamicAppDescriptor( DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag else -> DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag }, + diagnosticCodes = sameOrigin.diagnosticCodes, diagnostics = sameOrigin.diagnostics + acquired.successDiagnostic( app = app, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index af1895ca3..d79fce307 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -71,6 +71,12 @@ private fun DynamicServerProbeFailure.diagnostic(): String = when { else -> "The server API probe was not authorized: $name." } +private fun DynamicServerProbeFailure.diagnosticCode(): DynamicDiagnosticCode = when { + isInconclusive() -> DynamicDiagnosticCode.ServerProbeInconclusive + this == DynamicServerProbeFailure.UnsupportedContract -> DynamicDiagnosticCode.ServerContractUnsupported + else -> DynamicDiagnosticCode.ServerProbeAccessDenied +} + private fun DynamicServerProbeFailure.fallbackReason(): DynamicContractFallbackReason = when (this) { DynamicServerProbeFailure.AuthenticationRequired -> DynamicContractFallbackReason.ServerAuthenticationRequired DynamicServerProbeFailure.PermissionDenied -> DynamicContractFallbackReason.ServerPermissionDenied @@ -97,6 +103,7 @@ internal suspend fun discoverDynamicAppDescriptor( val identity = AppIdentity(app.id, app.name, "server-installed") val compiler = DynamicAppDescriptorCompiler() val diagnostics = mutableListOf() + val codes = mutableListOf() val probe = DynamicServerProbe() onProgress( @@ -105,7 +112,7 @@ internal suspend fun discoverDynamicAppDescriptor( "Checking the server API catalog", ), ) - val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics, probe) + val viewerSpecs = discoverOfficialViewerSpecIds(app.id, execute, diagnostics, codes, probe) viewerSpecs.forEach { specId -> onProgress( DynamicDescriptorDiscoveryProgress( @@ -128,6 +135,7 @@ internal suspend fun discoverDynamicAppDescriptor( val document = response.parseOpenApiDocument() if (document == null) { probe.record(response.contractBodyFailure()) + codes += DynamicDiagnosticCode.ContractInvalid diagnostics += "OCS API Viewer returned an invalid OpenAPI JSON body for $specId${response.contentTypeDiagnostic()}." return@forEach } @@ -149,6 +157,7 @@ internal suspend fun discoverDynamicAppDescriptor( sourcePath = path, acquisition = DynamicDescriptorAcquisition.OcsApiViewer, diagnostics = diagnostics, + diagnosticCodes = codes, ) } @@ -192,11 +201,16 @@ internal suspend fun discoverDynamicAppDescriptor( sourcePath = path, acquisition = DynamicDescriptorAcquisition.StaticAppAsset, diagnostics = diagnostics, + diagnosticCodes = codes, ) } diagnostics += "No valid static OpenAPI document was found in the app's advertised asset locations." - probe.failure?.let { failure -> diagnostics += failure.diagnostic() } + codes += DynamicDiagnosticCode.StaticContractUnavailable + probe.failure?.let { failure -> + diagnostics += failure.diagnostic() + codes += failure.diagnosticCode() + } onProgress( DynamicDescriptorDiscoveryProgress( @@ -214,6 +228,7 @@ internal suspend fun discoverDynamicAppDescriptor( sourcePath = null, acquisition = DynamicDescriptorAcquisition.MetadataFallback, diagnostics = diagnostics, + diagnosticCodes = codes, fallbackReason = probe.failure?.fallbackReason(), ) } @@ -222,6 +237,7 @@ private suspend fun discoverOfficialViewerSpecIds( appId: String, execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse, diagnostics: MutableList, + codes: MutableList, probe: DynamicServerProbe, ): List { val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(OCS_API_VIEWER_CATALOG_PATH)) } @@ -232,6 +248,7 @@ private suspend fun discoverOfficialViewerSpecIds( } .getOrNull() ?: return emptyList() if (response.status == 404) { + codes += DynamicDiagnosticCode.ApiViewerUnavailable diagnostics += "OCS API Viewer is not installed or enabled for this user (HTTP 404 at $OCS_API_VIEWER_CATALOG_PATH)." return emptyList() } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfoTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfoTest.kt index 6abe3efe2..444059f32 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfoTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfoTest.kt @@ -34,9 +34,9 @@ class DynamicContractInfoTest { acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, diagnostics = listOf( "The API viewer is not installed at https://private.example.test/path.", - "Verified example and imported openapi.json from its signed App Store package.", "Unclassified private diagnostic content", ), + diagnosticCodes = listOf(DynamicDiagnosticCode.ApiViewerUnavailable), ) val info = discovery.toContractInfo(null) @@ -56,10 +56,6 @@ class DynamicContractInfoTest { descriptor = descriptor(), sourcePath = "https://apps.nextcloud.com/packages/deck#appinfo/routes.php", acquisition = DynamicDescriptorAcquisition.SignedAppStoreStaticRoutes, - diagnostics = listOf( - "Verified deck 1.18.2 and derived 24 read-only endpoints from appinfo/routes.php " + - "in its signed App Store package. No writes were inferred.", - ), ).toContractInfo(null) assertEquals("Verified signed static read routes", info.acquisition) @@ -67,6 +63,55 @@ class DynamicContractInfoTest { assertEquals(listOf("verified-static-read-routes"), info.diagnosticCodes) } + @Test + fun `diagnostic codes come from typed events and never from diagnostic prose`() { + val typed = DynamicDescriptorDiscovery( + descriptor = descriptor(), + sourcePath = null, + acquisition = DynamicDescriptorAcquisition.MetadataFallback, + diagnostics = listOf("The server API probe was inconclusive: ServerError."), + diagnosticCodes = listOf(DynamicDiagnosticCode.StaticContractUnavailable, DynamicDiagnosticCode.ServerProbeInconclusive), + ) + val reworded = typed.copy(diagnostics = listOf("Completely reworded text.", "Only app metadata is available.")) + val expected = listOf("static-contract-unavailable", "server-probe-inconclusive") + assertEquals(expected, typed.toContractInfo(null).diagnosticCodes) + assertEquals(expected, reworded.toContractInfo(null).diagnosticCodes) + // Old trigger phrases without typed events produce no codes. + val prose = typed.copy(diagnosticCodes = emptyList(), diagnostics = listOf( + "OCS API Viewer is not installed", "No valid static OpenAPI document", "contained invalid OpenAPI", + "App Store contract acquisition failed: Network.", "Only app metadata is available.", + )) + assertEquals(emptyList(), prose.toContractInfo(null).diagnosticCodes) + } + + @Test + fun `every typed code and acquisition import keeps its stable code string`() { + assertEquals( + listOf( + "api-viewer-unavailable", "static-contract-unavailable", "app-store-acquisition-failed", + "server-probe-inconclusive", "server-probe-access-denied", "server-contract-unsupported", + "verified-static-read-routes", "verified-read-fallbacks", "signed-package-imported", + "linked-source-tag-imported", "contract-invalid", "metadata-only", + ), + DynamicDiagnosticCode.entries.map(DynamicDiagnosticCode::code), + ) + val imports = DynamicDescriptorAcquisition.entries.associateWith { acquisition -> + DynamicDescriptorDiscovery(descriptor(), null, acquisition).toContractInfo(null).diagnosticCodes + } + assertEquals(listOf("signed-package-imported"), imports.getValue(DynamicDescriptorAcquisition.SignedAppStorePackage)) + assertEquals(listOf("linked-source-tag-imported"), imports.getValue(DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag)) + assertEquals(listOf("verified-static-read-routes"), imports.getValue(DynamicDescriptorAcquisition.AppStoreLinkedStaticRoutes)) + assertEquals(listOf("verified-read-fallbacks"), imports.getValue(DynamicDescriptorAcquisition.SignedAppStoreMergedContract)) + assertEquals(listOf("verified-read-fallbacks"), imports.getValue(DynamicDescriptorAcquisition.AppStoreLinkedMergedContract)) + for (acquisition in listOf( + DynamicDescriptorAcquisition.OcsApiViewer, + DynamicDescriptorAcquisition.StaticAppAsset, + DynamicDescriptorAcquisition.MetadataFallback, + )) { + assertEquals(emptyList(), imports.getValue(acquisition), acquisition.name) + } + } + @Test fun `current record diagnostics explain included and omitted child candidates`() { val info = DynamicDescriptorDiscovery( From 158c9e184a009950dcff57bd9b9b0e1ba0a453ae Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 06:36:05 +0200 Subject: [PATCH 28/49] docs: date the reviewed contract and shared-ownership sections --- DYNAMIC_APP_DESCRIPTOR.md | 4 ++++ PLATFORMS.md | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 4049b2c17..7667dd6c7 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -184,6 +184,10 @@ typed acquisition kind, so rewording a diagnostic message never changes a code. ## Metadata fallback reasons +**Last reviewed: 2026-10-17.** This section describes source and deterministic +test behavior; the code may have changed since, and the linked sources are +authoritative. It is not a claim about a published release. + When no same-origin or App Store source yields a verified contract, discovery keeps the metadata-only descriptor and records a typed reason in [`DynamicContractFallback.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt). diff --git a/PLATFORMS.md b/PLATFORMS.md index 4b6260a56..4da2f3917 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -27,8 +27,10 @@ The app is built from four layers. Only the last one is platform-specific. ## Modules and source sets -This section describes where code lives today. The Gradle build has three -modules (`settings.gradle.kts`); the Rust crate is built separately. +**Last reviewed: 2026-10-17.** This section describes where code lives; the +code may have changed since, and the default branch is the source of truth. +The Gradle build has three modules (`settings.gradle.kts`); the Rust crate is +built separately. | Location | What it owns | | --- | --- | @@ -63,10 +65,11 @@ behaves identically on both JVM targets belongs in `ui/src/jvmMain`. Code that differs because of lifecycle, storage, or operating-system APIs stays in the platform module, with the difference documented and tested. -**Last reviewed: 2026-10-02.** This is a source inventory, not a plan. The code +**Last reviewed: 2026-10-17.** This is a source inventory, not a plan. The code may have changed since; the default branch is the source of truth. Similarity -figures came from comparing function bodies after removing the `Android` and -`Desktop` name prefixes. +figures came from comparing function bodies on 2026-10-02 after removing the +`Android` and `Desktop` name prefixes, and were not remeasured on the review +date. **Already shared.** All Compose UI, the sync domain model (planning, conflicts, checkpoints), groupware and media DAV models, virtual-file eviction @@ -90,7 +93,7 @@ its own HTTP request code, protocol adapters, XML parsers, and sync executor. | Medium | Dynamic discovery cache. Most of the logic is identical | `AndroidDynamicDiscoveryCache.kt` | `DesktopDynamicDiscoveryCache.kt` | One `jvmMain` cache | | Medium | Deck card draft codec, capacity, and migration policy | `AndroidDeckCardDraftStore.kt` | `DesktopDeckCardDraftStore.kt` | `commonMain` policy behind a storage port | | Medium | File read cache. Android skips leased entries during eviction; desktop eviction has no lease check | `AndroidFileReadCache.kt`, `AndroidVirtualFileCache.kt` | `DesktopFileReadCache.kt` | A `jvmMain` blob store | -| Medium | Account removal stages and journals | Five `AndroidAccountRemoval*` files | `DesktopAccountRemoval.kt` | A `commonMain` removal state machine | +| Medium | Account removal stages and journals | Four `AndroidAccountRemoval*` files | `DesktopAccountRemoval.kt` | A `commonMain` removal state machine | | Medium | Pure policy kept in platform code | `AndroidFileSyncContentEvidence.kt` | `DesktopFileSyncRetryPolicy.kt`, `DesktopFileSyncBatchResult.kt`, `DesktopFileSyncContentSlices.kt` | `commonMain` | | Low | Small identical helpers: streaming multipart body, document-editing capability parsing, bounded reads, `sha256Hex` copies, update-channel preferences | Several files | Several files | `jvmMain` | From a58a41e8227ae4359d4b7bc0cd3a6004e6632482 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:05:29 +0000 Subject: [PATCH 29/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 824a89c6d..d209ccb72 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,10 +585,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "b6569bba81ee19a8987b458b10bbd7ae972c9d7e3346707ced08247515c79743", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "79bb347d92be621079008d3ab4fcde43167fe9d37e182e6762e100f3ad2d1215", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "8d3c7ea1ae4cf577c71f7e318c00e35e567d1715d2543278bdcb973126b355fa", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "c24734ae869fb6fa144c257961c04285e24de9209761ea3cacc99a9208ca118a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "3a5b7e7951847d9fff084cfe79bc12b21c8ed8319392782e6b704a7f64987151", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "d79ec4f9fbd8fac36e66af7fc692941f81f2754bc873de417c0ee60b4c210403", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationCache.kt": "af27d14641fde05a5c1d1688676102c6630ccce4cd8d06ab0e3d58c95432aa8f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMessageReadSelection.kt": "a065cfe7e33d6f154849210a4a140d3c87c7705453f9d78f6275236f71f0cc0e", @@ -596,11 +596,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "4144e635162d2a3757fb946e954e3e8a833732797925db4ce6b49487a71c8359", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "e9899cc16a75c8a6b5a4d4cfb1c4d273f93dcad9845327862528dfdb3c38682d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "bb7bcd1b8c71f7de8d5f999d46bad554bd88554fcb9a05a22b8c1f3df4ae1895", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "0075deccd3f651b1fd43a21b0d2bdaded4ba92f122265844b77f5a694030f0bd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", From e9d8c2a9eae5be27dc63bf1c77c18851eb85e113 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 07:50:02 +0200 Subject: [PATCH 30/49] fix: refresh past cached contracts, name unreadable catalogs, and bound discovery diagnostics - An explicit refresh now looks past cached verified contracts as well as catalogs. A newly verified contract replaces the cached one, a failed refresh keeps it, and a refresh refused by the window serves it. When an acquired contract cannot be parsed or compiled, discovery discards the cached contract for exactly that request, so the next automatic discovery acquires it again. The cache policy types are renamed for what they now control. - An unreadable catalog document has its own reason and copy instead of the malformed-entry explanation. - Same-origin discovery diagnostics no longer include exception messages, the catalog path, exact statuses, or raw Content-Type values; they record a stage, a typed probe result, an HTTP status class, and a bounded media type. --- DYNAMIC_APP_DESCRIPTOR.md | 32 +++-- .../AndroidNextcloudServices.kt | 2 +- .../contracts/AppStoreCatalogCache.kt | 7 +- .../contracts/AppStoreCatalogReleases.kt | 15 ++- .../contracts/ContractAcquisitionOutcome.kt | 19 +++ .../SignedAppStoreContractAcquirer.kt | 45 ++++--- .../contracts/VerifiedContractCache.kt | 11 ++ .../contracts/CatalogRefreshTest.kt | 12 +- .../contracts/ContractSourceLimitsTest.kt | 10 +- .../contracts/MalformedCatalogReleaseTest.kt | 10 +- .../contracts/UnreadableContractSourceTest.kt | 2 +- .../contracts/VerifiedContractCacheTest.kt | 4 + .../contracts/VerifiedContractRefreshTest.kt | 118 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/DynamicContractFallback.kt | 59 +++++++-- .../app/DynamicNativeRuntime.kt | 11 +- .../app/DynamicSameOriginDiscovery.kt | 31 +++-- .../app/DesktopNextcloudServices.kt | 2 +- .../app/AppStoreCatalogRetryTest.kt | 49 +++++++- .../DynamicContractFallbackDiscoveryTest.kt | 1 + .../DynamicDiscoveryDiagnosticPrivacyTest.kt | 98 +++++++++++++++ .../app/JvmAppStoreContractAcquisitionTest.kt | 37 +++++- .../app/JvmAppStoreContractAcquisition.kt | 30 ++++- 23 files changed, 513 insertions(+), 94 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractRefreshTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnosticPrivacyTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 7667dd6c7..b30da54fb 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -198,6 +198,8 @@ The fallback card derives its title, explanation, and action from that reason: - the App Store lists the app, but the entry that would be selected lacks a required value such as its certificate, download, signature, or signature digest; +- an App Store catalog document could not be read at all, so no entries were + available to check; - the app's linked source refused or rate limited the last fallback request (HTTP 403 or 429), so a contract may still exist; - an explicit retry came less than a minute after the previous catalog check @@ -223,8 +225,14 @@ so a local storage failure is never reported as an unreachable App Store. translates them once for both JVM targets. It also catches `LinkageError`. If acquisition code cannot initialize on a runtime, for example because the Android ICU engine rejects a regular expression, the result is a client-fault -fallback, not an exception class name. Diagnostics and product copy never -contain exception text, and a fallback reason is never persisted. +fallback, not an exception class name. Discovery diagnostics, fallback copy, +and contract information never contain exception messages, request URLs, +hosts, paths, header values, or response bodies. Same-origin diagnostics name +only a stage (catalog, spec, body, or compilation), a typed probe result, an +HTTP status class such as `HTTP 5xx`, a sanitized spec identifier, and a +bounded media type. A fallback reason is never persisted. The original +exception remains only as the cause of the typed acquisition exception, for +local debugging; it is never written to diagnostics or support reports. A completed acquisition without a contract is a typed outcome, not a bare `null`. @@ -235,14 +243,15 @@ and the shared JVM boundary carries that reason to the fallback card. Catalog parsing records which required value made each dropped entry unusable. A dropped entry counts only when it would have been selected: the exact installed version, a patch-compatible release, or an entry whose version cannot be read. -A catalog document that is truncated, empty, not JSON, or not an array is also -a malformed source; it is never cached, so checking again downloads it again. +A catalog document that is truncated, empty, not JSON, or not an array is an +unreadable catalog, a reason of its own, because no entry could be read; it is +never cached, so checking again downloads it again. A corrupt or mismatched App Store-linked source archive yields no candidate, like an archive without a contract, instead of an unexpected client fault. App Store catalogs are cached for six hours. The first discovery of an app screen uses the cache. A discovery started by, or after, an explicit retry on -that screen carries `AppStoreCatalogPolicy.RefreshCatalog` through the platform +that screen carries `AppStoreCachePolicy.Refresh` through the platform boundary to the acquirer, which downloads each catalog again at most once per minute per catalog URL. A retry inside that window reuses the cached copy; if none is available, for example after a cold start or an expired entry, it makes @@ -252,6 +261,15 @@ without a cached catalog downloads it normally. A forced download that fails or is unreadable leaves the cached catalog in place, because only a readable catalog replaces it. +The same policy covers verified contracts. Automatic discovery serves a cached +verified contract. A refresh looks past it and downloads the package or linked +contract again; a newly verified contract replaces the cached one, while a +failed refresh leaves it in place. A refresh refused by the window serves the +cached contract when one exists. When an acquired contract cannot be parsed or +compiled, discovery discards the cached contract for exactly that app, server +version, and installed version, so the next automatic discovery acquires it +again; other cached contracts stay. + [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. When probes disagree, the highest-precedence result wins, and the first one @@ -296,7 +314,7 @@ only genuine client faults remain unexpected. Size limits throw | Verified contract cache | Missing, expired, or unreadable entry | Ignored; acquisition continues | | Catalog and package requests | No response; non-success status; redirect away from HTTPS | Network; source unavailable; verification failed | | Catalog and package bodies | Declared or streamed size over the limit | Source unusable | -| Catalog document | Truncated, empty, not JSON, or not an array | Malformed source, never cached | +| Catalog document | Truncated, empty, not JSON, or not an array | Unreadable catalog, never cached | | Catalog entry | Missing value, or a download that is not an HTTP URL | Malformed source when it would be selected | | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | @@ -308,7 +326,7 @@ only genuine client faults remain unexpected. Size limits throw | Linked archive request | HTTP 403 or 429 | Source temporarily unavailable | | Linked archive content | Corrupt, unsafe path, identity mismatch, malformed or misencoded `info.xml`, size limit | No candidate | | Linked JSON or YAML documents | Unreadable | Skipped | -| Acquired contract | Unreadable JSON or a descriptor that cannot be compiled | Unsupported contract | +| Acquired contract | Unreadable JSON or a descriptor that cannot be compiled | Unsupported contract; that cached contract is discarded | | Contract module class loading | `LinkageError` | Client fault | | Same-origin probes | See the precedence list above | Typed probe result | | Remaining unexpected failures | XML parser configuration, internal invariants | Client fault | diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index be981a0d4..3ebabed86 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2957,7 +2957,7 @@ internal class AndroidNextcloudServices( } override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = - withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer::acquireOutcome) } + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer) } override suspend fun listActivities( session: NextcloudSession, diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt index 3e7f10779..7ff6c5532 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogCache.kt @@ -27,8 +27,11 @@ internal fun AppStoreCatalogCache.storeBestEffort(url: String, bytes: ByteArray) } } -/** Whether a catalog read may reuse the cached copy. Only an explicit user retry asks for [Refresh]. */ -enum class CatalogPolicy { UseCache, Refresh } +/** + * Whether acquisition may reuse cached catalogs and verified contracts. Only an explicit user retry + * asks for [Refresh]; cached copies stay until a newly verified result replaces them. + */ +enum class AcquisitionCachePolicy { UseCache, Refresh } /** * Bounds explicit catalog refreshes to one forced download per catalog URL per interval. A retry diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 5dfeed677..193221d3b 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -22,12 +22,20 @@ internal enum class MalformedCatalogField { /** A catalog entry for the requested app that parsing dropped. A null version could not be read. */ internal data class MalformedCatalogEntry(val version: String?, val field: MalformedCatalogField) +/** Where a catalog read came from. */ +internal enum class CatalogSource { + Downloaded, + Cached, + + /** An explicit retry was refused by the refresh window and no cached copy was available. */ + RefreshRefused, +} + /** Usable releases of one app in one catalog, plus the entries that were present but malformed. */ internal data class CatalogReleases( val releases: List, val malformed: List = emptyList(), - /** True when an explicit retry was refused by the refresh window and nothing cached was read. */ - val refreshDenied: Boolean = false, + val source: CatalogSource = CatalogSource.Downloaded, ) { /** False when the whole document was unreadable; such a document must not be cached. */ val documentReadable: Boolean get() = malformed.none { entry -> entry.field == MalformedCatalogField.Document } @@ -43,6 +51,9 @@ internal fun unmatchedReleaseOutcome( platformCatalog: CatalogReleases, allCatalog: CatalogReleases?, ): ContractAcquisitionOutcome.Unavailable { + if (!platformCatalog.documentReadable || allCatalog?.documentReadable == false) { + return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.UnreadableCatalog) + } fun MalformedCatalogEntry.isExact(): Boolean = version == null || version == installedVersion val malformedMatch = platformCatalog.malformed.any { entry -> entry.isExact() || isCompatibleReleaseVersion(requireNotNull(entry.version), installedVersion) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt index f8d846ca1..9485ed862 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -20,6 +20,9 @@ enum class ContractUnavailableReason { */ MalformedSource, + /** A catalog document was truncated, empty, not JSON, or not an array, so no entries could be read. */ + UnreadableCatalog, + /** * An explicit retry asked for a fresh catalog inside its refresh window, and no cached copy was * available to reuse, so no App Store request was made. See [CatalogRefreshThrottle]. @@ -34,6 +37,22 @@ sealed interface ContractAcquisitionOutcome { data class Unavailable(val reason: ContractUnavailableReason) : ContractAcquisitionOutcome } +/** + * Ends a refresh early when the refresh window refused it. A cached verified contract is served, + * because nothing newer can be fetched yet; without one and without a cached catalog, nothing was + * read, which is [ContractUnavailableReason.RecentlyChecked]. Returns null to continue normally. + */ +internal fun refreshShortcut( + catalog: CatalogReleases, + cachePolicy: AcquisitionCachePolicy, + cached: VerifiedOpenApiContract?, +): ContractAcquisitionOutcome? = when { + cachePolicy != AcquisitionCachePolicy.Refresh || catalog.source == CatalogSource.Downloaded -> null + cached != null -> ContractAcquisitionOutcome.Acquired(cached) + catalog.source == CatalogSource.RefreshRefused -> ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) + else -> null +} + /** Evidence gathered by one uncoalesced acquisition that decides its no-contract reason. */ internal class ContractAcquisitionAttempt { var sourceTemporarilyUnavailable: Boolean = false diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 7ffd3dcbb..f5ab02085 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -35,7 +35,7 @@ class SignedAppStoreContractAcquirer( ) { private val verifiedContracts = ConcurrentHashMap() private val inFlightAcquisitions = - ConcurrentHashMap, CompletableFuture>() + ConcurrentHashMap, CompletableFuture>() init { if (requireHttps) { @@ -55,13 +55,13 @@ class SignedAppStoreContractAcquirer( fun acquire(request: ContractAcquisitionRequest): VerifiedOpenApiContract? = (acquireOutcome(request) as? ContractAcquisitionOutcome.Acquired)?.contract - /** [catalogPolicy] is [CatalogPolicy.Refresh] only for an explicit user retry; see [CatalogRefreshThrottle]. */ + /** [cachePolicy] is [AcquisitionCachePolicy.Refresh] only for an explicit user retry; see [CatalogRefreshThrottle]. */ fun acquireOutcome( request: ContractAcquisitionRequest, - catalogPolicy: CatalogPolicy = CatalogPolicy.UseCache, + cachePolicy: AcquisitionCachePolicy = AcquisitionCachePolicy.UseCache, ): ContractAcquisitionOutcome { val owner = CompletableFuture() - val existing = inFlightAcquisitions.putIfAbsent(request to catalogPolicy, owner) + val existing = inFlightAcquisitions.putIfAbsent(request to cachePolicy, owner) if (existing != null) { return try { existing.get() @@ -73,41 +73,39 @@ class SignedAppStoreContractAcquirer( } } return try { - acquireUncoalesced(request, catalogPolicy).also { acquired -> owner.complete(acquired) } + acquireUncoalesced(request, cachePolicy).also { acquired -> owner.complete(acquired) } } catch (failure: Throwable) { owner.completeExceptionally(failure) throw failure } finally { - inFlightAcquisitions.remove(request to catalogPolicy, owner) + inFlightAcquisitions.remove(request to cachePolicy, owner) } } private fun acquireUncoalesced( request: ContractAcquisitionRequest, - catalogPolicy: CatalogPolicy, + cachePolicy: AcquisitionCachePolicy, ): ContractAcquisitionOutcome { requireValidRequest(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } val coreServerVersion = normalizeServerVersion(request.serverVersion) request.installedAppVersion?.let { version -> requireValidRequest(version.matches(APP_VERSION_PATTERN)) { "The installed app version is invalid." } } - verifiedContracts[request]?.let { return ContractAcquisitionOutcome.Acquired(it) } - verifiedContractCache.load(request)?.let { cached -> - verifiedContracts[request] = cached - return ContractAcquisitionOutcome.Acquired(cached) - } + val cached = verifiedContracts[request] ?: verifiedContractCache.load(request)?.also { verifiedContracts[request] = it } + // A refresh looks past a cached contract, which stays until a newly verified contract replaces it. + if (cached != null && cachePolicy == AcquisitionCachePolicy.UseCache) return ContractAcquisitionOutcome.Acquired(cached) val compatibleCatalogUrl = "${appStoreBaseUrl.trimEnd('/')}/platform/$coreServerVersion/apps.json" - val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId, catalogPolicy) - if (platformCatalog.refreshDenied) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) + val platformCatalog = fetchCatalogReleases(compatibleCatalogUrl, request.appId, cachePolicy) + refreshShortcut(platformCatalog, cachePolicy, cached)?.let { return it } val platformReleases = platformCatalog.releases val exactPlatformRelease = request.installedAppVersion?.let { installed -> platformReleases.firstOrNull { release -> release.version == installed } } val allCatalog = request.installedAppVersion?.takeIf { exactPlatformRelease == null }?.let { - fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId, catalogPolicy) + fetchCatalogReleases("${appStoreBaseUrl.trimEnd('/')}/apps.json", request.appId, cachePolicy) } - if (allCatalog?.refreshDenied == true) return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) + allCatalog?.let { catalog -> refreshShortcut(catalog, cachePolicy, cached)?.let { return it } } val exactRelease = exactPlatformRelease ?: allCatalog?.releases?.firstOrNull { release -> release.version == request.installedAppVersion } val compatibleReleases = compatibleReleaseCandidates( @@ -183,6 +181,12 @@ class SignedAppStoreContractAcquirer( return cacheContract(request, verified.asPackageContract(request, release)) } + /** Forgets the verified contract for [request], for example after it proved unusable downstream. */ + fun invalidateVerifiedContract(request: ContractAcquisitionRequest) { + verifiedContracts.remove(request) + verifiedContractCache.remove(request) + } + private fun VerifiedPackageContract.asPackageContract( request: ContractAcquisitionRequest, release: AppStoreRelease, @@ -452,16 +456,19 @@ class SignedAppStoreContractAcquirer( private fun fetchCatalogReleases( catalogUrl: String, appId: String, - catalogPolicy: CatalogPolicy, + cachePolicy: AcquisitionCachePolicy, ): CatalogReleases { requireSecureUrl(catalogUrl) // A refresh only bypasses the cached copy; the copy stays until a readable catalog replaces it. - val refreshAllowed = catalogPolicy == CatalogPolicy.Refresh && catalogRefreshThrottle.tryRefresh(catalogUrl) + val refreshAllowed = cachePolicy == AcquisitionCachePolicy.Refresh && catalogRefreshThrottle.tryRefresh(catalogUrl) catalogCache.load(catalogUrl)?.takeUnless { refreshAllowed }?.let { cached -> return parseReleases(cached.decodeToString(), appId, catalogUrl, requireOfficialGitHubHost = requireHttps) + .copy(source = CatalogSource.Cached) } // A retry inside the refresh window never reaches the App Store, even without a cached copy. - if (catalogPolicy == CatalogPolicy.Refresh && !refreshAllowed) return CatalogReleases(emptyList(), refreshDenied = true) + if (cachePolicy == AcquisitionCachePolicy.Refresh && !refreshAllowed) { + return CatalogReleases(emptyList(), source = CatalogSource.RefreshRefused) + } val response = execute(Request.Builder().url(catalogUrl).get().build()) val bytes = response.use { catalogResponse -> requireSuccessfulSource(catalogResponse) { diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt index 62679b8ed..4a2082a3a 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt @@ -15,6 +15,9 @@ import org.json.JSONObject interface VerifiedContractCache { fun load(request: ContractAcquisitionRequest): VerifiedOpenApiContract? fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) + + /** Forgets only the contract cached for [request]; other entries stay. */ + fun remove(request: ContractAcquisitionRequest) } class MemoryVerifiedContractCache : VerifiedContractCache { @@ -25,6 +28,10 @@ class MemoryVerifiedContractCache : VerifiedContractCache { override fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) { entries[request] = contract } + + override fun remove(request: ContractAcquisitionRequest) { + entries.remove(request) + } } /** @@ -74,6 +81,10 @@ class FileVerifiedContractCache( } } + override fun remove(request: ContractAcquisitionRequest): Unit = synchronized(lock) { + cacheFile(request).delete() + } + private fun cacheFile(request: ContractAcquisitionRequest): File { val identity = listOf( CACHE_FORMAT_VERSION, diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt index 58a16e832..95ca69433 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogRefreshTest.kt @@ -24,7 +24,7 @@ class CatalogRefreshTest { server.enqueue(MockResponse(body = catalog(server, "4.0.2"))) server.enqueue(MockResponse(body = "package")) - val refreshed = acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) + val refreshed = acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) assertEquals("4.0.2", assertIs(refreshed).contract.contractVersion) assertEquals(4, server.requestCount) } @@ -56,12 +56,12 @@ class CatalogRefreshTest { // Each catalog URL has its own refresh window, so the full catalog is refreshed too. if (failed.code != 503) server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) if (failed.code == 503) { - assertFailsWith { acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) } + assertFailsWith { acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) } } else { // The unreadable platform catalog is not cached and makes the source malformed. assertEquals( - unavailable(ContractUnavailableReason.MalformedSource), - acquirer.acquireOutcome(request(), CatalogPolicy.Refresh), + unavailable(ContractUnavailableReason.UnreadableCatalog), + acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh), ) } assertEquals(cached, catalogUrls(server).map { url -> cache.load(url)?.decodeToString() }) @@ -78,12 +78,12 @@ class CatalogRefreshTest { repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } acquirer.acquireOutcome(request()) repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } - repeat(5) { acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) } + repeat(5) { acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) } assertEquals(4, server.requestCount) now += CatalogRefreshThrottle.DEFAULT_CATALOG_REFRESH_INTERVAL_MILLIS repeat(2) { server.enqueue(MockResponse(body = catalog(server, "5.1.0"))) } - acquirer.acquireOutcome(request(), CatalogPolicy.Refresh) + acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) assertEquals(6, server.requestCount) } } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt index ae6f925c4..8e99bb6c0 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractSourceLimitsTest.kt @@ -22,9 +22,9 @@ class ContractSourceLimitsTest { server.start() val acquirer = acquirer(server) server.enqueue(MockResponse(body = "[unreadable")) - assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + assertEquals(unavailable(ContractUnavailableReason.UnreadableCatalog), acquirer.acquireOutcome(unversioned(), AcquisitionCachePolicy.Refresh)) repeat(3) { - assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), AcquisitionCachePolicy.Refresh)) } assertEquals(1, server.requestCount) } @@ -42,9 +42,9 @@ class ContractSourceLimitsTest { assertEquals(unavailable(ContractUnavailableReason.NoMatchingRelease), acquirer.acquireOutcome(unversioned())) now += 7L * 60L * 60L * 1_000L server.enqueue(MockResponse(code = 503, body = "unavailable")) - assertFailsWith { acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh) } + assertFailsWith { acquirer.acquireOutcome(unversioned(), AcquisitionCachePolicy.Refresh) } repeat(2) { - assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), CatalogPolicy.Refresh)) + assertEquals(unavailable(ContractUnavailableReason.RecentlyChecked), acquirer.acquireOutcome(unversioned(), AcquisitionCachePolicy.Refresh)) } assertEquals(2, server.requestCount) } @@ -60,7 +60,7 @@ class ContractSourceLimitsTest { val acquirer = acquirer(server) repeat(3) { server.enqueue(MockResponse(body = "[unreadable")) } repeat(3) { - assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquirer.acquireOutcome(unversioned())) + assertEquals(unavailable(ContractUnavailableReason.UnreadableCatalog), acquirer.acquireOutcome(unversioned())) } assertEquals(3, server.requestCount) } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt index cb115c2c7..17de24491 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/MalformedCatalogReleaseTest.kt @@ -59,6 +59,12 @@ class MalformedCatalogReleaseTest { } } + @Test + fun `an unreadable catalog document is distinct from a malformed matching entry`() { + assertEquals(unavailable(ContractUnavailableReason.UnreadableCatalog), acquireFrom(JSONArray("[]"), raw = "[truncated")) + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquireFrom(catalog(release("4.0.2", omit = "signature")))) + } + @Test fun `an app without a releases list is a malformed source`() { val catalog = JSONArray().put(JSONObject().put("id", "cospend").put("certificate", "certificate")) @@ -98,10 +104,10 @@ class MalformedCatalogReleaseTest { } } - private fun acquireFrom(catalog: JSONArray): ContractAcquisitionOutcome = MockWebServer().use { server -> + private fun acquireFrom(catalog: JSONArray, raw: String? = null): ContractAcquisitionOutcome = MockWebServer().use { server -> server.start() // The installed version is absent from the platform catalog, so the full catalog is read too. - repeat(2) { server.enqueue(MockResponse(body = catalog.toString())) } + repeat(2) { server.enqueue(MockResponse(body = raw ?: catalog.toString())) } acquirer(server) { error("No package may be verified.") }.acquireOutcome(request()) } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt index 7356abf5b..184eba4c2 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/UnreadableContractSourceTest.kt @@ -23,7 +23,7 @@ class UnreadableContractSourceTest { repeat(2) { server.enqueue(MockResponse(body = body)) } val cache = RecordingCatalogCache() val outcome = acquirer(server, cache).acquireOutcome(request()) - assertEquals(ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.MalformedSource), outcome, body) + assertEquals(ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.UnreadableCatalog), outcome, body) assertEquals(emptyList(), cache.stored, body) assertEquals(2, server.requestCount, body) } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt index b54648663..976e30464 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt @@ -74,6 +74,8 @@ class VerifiedContractCacheTest { request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract, ) = error("A cache hit must not be stored again.") + + override fun remove(request: ContractAcquisitionRequest) = error("A cache hit must not be removed.") } val loaded = SignedAppStoreContractAcquirer(verifiedContractCache = cache).acquire(request) @@ -97,6 +99,8 @@ class VerifiedContractCacheTest { } override fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) = Unit + + override fun remove(request: ContractAcquisitionRequest) = Unit } val acquirer = SignedAppStoreContractAcquirer(verifiedContractCache = cache) val executor = Executors.newFixedThreadPool(4) diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractRefreshTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractRefreshTest.kt new file mode 100644 index 000000000..cdb5c17ca --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractRefreshTest.kt @@ -0,0 +1,118 @@ +package dev.obiente.nextcloudnative.contracts + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class VerifiedContractRefreshTest { + private var now = 50_000_000L + private var packageDocument = "{\"version\":1}" + + @Test + fun `a refresh bypasses a cached contract and replaces it with the corrected one`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + enqueueCatalogAndPackage(server) + assertEquals("{\"version\":1}", document(acquirer.acquireOutcome(request()))) + + packageDocument = "{\"version\":2}" + enqueueCatalogAndPackage(server) + assertEquals("{\"version\":2}", document(acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh))) + val requests = server.requestCount + + // Automatic discovery serves the replacement from the cache. + assertEquals("{\"version\":2}", document(acquirer.acquireOutcome(request()))) + assertEquals(requests, server.requestCount) + } + } + + @Test + fun `a failed refresh keeps the cached contract for automatic discovery`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + enqueueCatalogAndPackage(server) + acquirer.acquireOutcome(request()) + server.enqueue(MockResponse(code = 503, body = "unavailable")) + assertFailsWith { acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) } + assertEquals("{\"version\":1}", document(acquirer.acquireOutcome(request()))) + assertEquals(3, server.requestCount) + } + } + + @Test + fun `a refresh refused by the throttle serves the cached contract`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + enqueueCatalogAndPackage(server) + acquirer.acquireOutcome(request()) + server.enqueue(MockResponse(code = 503, body = "unavailable")) + assertFailsWith { acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh) } + assertEquals("{\"version\":1}", document(acquirer.acquireOutcome(request(), AcquisitionCachePolicy.Refresh))) + assertEquals(3, server.requestCount) + } + } + + @Test + fun `an invalidated contract is acquired again by the next automatic discovery`() { + val directory = Files.createTempDirectory("verified-contracts").toFile() + try { + MockWebServer().use { server -> + server.start() + val cache = FileVerifiedContractCache(directory, nowMillis = { now }) + val acquirer = acquirer(server, cache) + enqueueCatalogAndPackage(server) + acquirer.acquireOutcome(request()) + val other = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.1") + cache.store(other, cache.load(request())!!.copy(appVersion = "4.0.1")) + + acquirer.invalidateVerifiedContract(request()) + assertNull(cache.load(request())) + assertNotNull(cache.load(other)) + + packageDocument = "{\"version\":2}" + server.enqueue(MockResponse(body = "package")) + // The catalog stays cached; only the package is downloaded again. + assertEquals("{\"version\":2}", document(acquirer.acquireOutcome(request()))) + assertEquals(3, server.requestCount) + } + } finally { + directory.deleteRecursively() + } + } + + private fun document(outcome: ContractAcquisitionOutcome): String = + assertIs(outcome).contract.document + + private fun request() = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun enqueueCatalogAndPackage(server: MockWebServer) { + val download = server.url("downloads/cospend-4.0.2.tar.gz") + server.enqueue(MockResponse(body = """[{"id":"cospend","certificate":"c","releases":[""" + + """{"version":"4.0.2","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""")) + server.enqueue(MockResponse(body = "package")) + } + + private fun acquirer(server: MockWebServer, cache: VerifiedContractCache = MemoryVerifiedContractCache()) = + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = + VerifiedPackageContract(release.appId, release.version, "openapi.json", packageDocument) + }, + verifiedContractCache = cache, + catalogRefreshThrottle = CatalogRefreshThrottle(nowMillis = { now }), + ) +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 1c4a2f700..36aa3c28b 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -8,7 +8,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|11 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|864 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1801 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2225 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2222 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2515 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index c7e4027e1..2c3951410 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -40,28 +40,42 @@ enum class AppStoreContractUnavailableReason { /** The catalog lists the app, but the entry that would be selected lacks data needed to verify it. */ MalformedSource, + /** A catalog document could not be read at all, so no entries were available to check. */ + UnreadableCatalog, + /** An explicit retry fell inside the catalog refresh window and no cached catalog could be reused. */ RecentlyChecked, } /** Whether App Store acquisition may reuse its cached catalog. Only an explicit user retry refreshes it. */ -enum class AppStoreCatalogPolicy { UseCache, RefreshCatalog } +enum class AppStoreCachePolicy { + UseCache, + + /** Look past cached catalogs and verified contracts; cached copies stay until replaced. */ + Refresh, + + /** Forget the verified contract cached for this exact request after it proved unusable. */ + DiscardCachedContract, +} /** What discovery asks of [NextcloudPlatformServices.acquireSignedOpenApiContract]. */ data class AppStoreContractRequest( val appId: String, val serverVersion: String, val installedAppVersion: String?, - val catalogPolicy: AppStoreCatalogPolicy = AppStoreCatalogPolicy.UseCache, + val cachePolicy: AppStoreCachePolicy = AppStoreCachePolicy.UseCache, ) /** Discovery services whose App Store requests use [policy]; every other member delegates unchanged. */ -internal fun NextcloudPlatformServices.withAppStoreCatalogPolicy(policy: AppStoreCatalogPolicy): NextcloudPlatformServices { - if (policy == AppStoreCatalogPolicy.UseCache) return this +internal fun NextcloudPlatformServices.withAppStoreCachePolicy(policy: AppStoreCachePolicy): NextcloudPlatformServices { + if (policy == AppStoreCachePolicy.UseCache) return this val delegate = this return object : NextcloudPlatformServices by delegate { override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = - delegate.acquireSignedOpenApiContract(request.copy(catalogPolicy = policy)) + // Only a plain acquisition takes the policy; a discard request keeps its own intent. + delegate.acquireSignedOpenApiContract( + if (request.cachePolicy == AppStoreCachePolicy.UseCache) request.copy(cachePolicy = policy) else request, + ) } } @@ -70,7 +84,7 @@ internal fun NextcloudPlatformServices.withAppStoreCatalogPolicy(policy: AppStor * explicit retry on that screen asks for a fresh catalog; the platform acquirer bounds how often. */ internal fun NextcloudPlatformServices.forDiscoveryAttempt(attempt: Int): NextcloudPlatformServices = - withAppStoreCatalogPolicy(if (attempt > 0) AppStoreCatalogPolicy.RefreshCatalog else AppStoreCatalogPolicy.UseCache) + withAppStoreCachePolicy(if (attempt > 0) AppStoreCachePolicy.Refresh else AppStoreCachePolicy.UseCache) /** Result of [NextcloudPlatformServices.acquireSignedOpenApiContract]. Failures are thrown instead. */ sealed interface AppStoreContractAcquisition { @@ -98,6 +112,7 @@ enum class DynamicContractFallbackReason { NoMatchingRelease, SourceTemporarilyUnavailable, MalformedSource, + UnreadableCatalog, RecentlyChecked, SourceUnusable, @@ -134,6 +149,7 @@ internal fun AppStoreContractUnavailableReason.fallbackReason(): DynamicContract DynamicContractFallbackReason.SourceTemporarilyUnavailable AppStoreContractUnavailableReason.NoUsableContract -> DynamicContractFallbackReason.NoVerifiedContract AppStoreContractUnavailableReason.MalformedSource -> DynamicContractFallbackReason.MalformedSource + AppStoreContractUnavailableReason.UnreadableCatalog -> DynamicContractFallbackReason.UnreadableCatalog AppStoreContractUnavailableReason.RecentlyChecked -> DynamicContractFallbackReason.RecentlyChecked } @@ -170,9 +186,28 @@ internal fun DynamicDescriptorDiscovery.withAcquisitionFailure(kind: AppStoreCon DynamicDiagnosticCode.AppStoreAcquisitionFailed, ) -/** Keeps the metadata fallback when an acquired contract cannot be parsed or compiled. */ -internal fun DynamicDescriptorDiscovery.withUnusableAcquiredContract(diagnostic: String): DynamicDescriptorDiscovery = - withFallback(DynamicContractFallbackReason.UnsupportedContract, diagnostic, DynamicDiagnosticCode.ContractInvalid) +/** Which step found an acquired App Store contract unusable. */ +internal enum class UnusableContractStage(val diagnostic: String) { + Parsing("The acquired App Store contract contained invalid JSON."), + Compilation("The acquired App Store contract could not be compiled into a native read surface."), +} + +/** + * Keeps the metadata fallback when an acquired contract cannot be parsed or compiled, and discards the + * verified contract cached for exactly this request, so the next automatic discovery acquires it again + * instead of serving it until the cache expires. Other cached contracts stay. + */ +internal suspend fun DynamicDescriptorDiscovery.withUnusableAcquiredContract( + services: NextcloudPlatformServices, + request: AppStoreContractRequest, + stage: UnusableContractStage, +): DynamicDescriptorDiscovery { + // Invalidation is best effort; the fallback stays truthful even when the cache cannot be cleared. + runCatchingPreservingCancellation { + services.acquireSignedOpenApiContract(request.copy(cachePolicy = AppStoreCachePolicy.DiscardCachedContract)) + } + return withFallback(DynamicContractFallbackReason.UnsupportedContract, stage.diagnostic, DynamicDiagnosticCode.ContractInvalid) +} /** Keeps the metadata fallback when the App Store boundary completed without a contract. */ internal fun DynamicDescriptorDiscovery.withUnavailable( @@ -256,6 +291,12 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "to administrators, and the App Store offered no verified contract. Only app details are shown.", "Check again", ) + DynamicContractFallbackReason.UnreadableCatalog -> DynamicContractFallbackNotice( + "App Store catalog unreadable", + "The Nextcloud App Store returned a catalog that could not be read, so no app entries were " + + "available to check. Try again later.", + "Try again", + ) DynamicContractFallbackReason.RecentlyChecked -> DynamicContractFallbackNotice( "Checked moments ago", "nati.ve checked the App Store for this app less than a minute ago and has no saved catalog to " + diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 6bcbbe72a..00754217d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -154,9 +154,10 @@ suspend fun discoverDynamicAppDescriptor( "Finding a verified app package", ), ) + val contractRequest = AppStoreContractRequest(contractAppId, coreVersion, installedVersion) val acquisition = observeDynamicDiscoveryStage(DynamicDiscoveryStage.PackageAcquisition, services::recordSupportDiagnostic, available = { it is AppStoreContractAcquisition.Acquired }) { - services.acquireSignedOpenApiContract(AppStoreContractRequest(contractAppId, coreVersion, installedVersion)) + services.acquireSignedOpenApiContract(contractRequest) }.getOrElse { failure -> // Only the typed kind enters diagnostics and copy; exception text and class names stay local. return sameOrigin.withAcquisitionFailure(failure.appStoreContractFailureKind()) @@ -168,9 +169,7 @@ suspend fun discoverDynamicAppDescriptor( val versionStatus = acquired.effectiveDynamicContractVersionStatus(observedVersionStatus) val document = observeDynamicDiscoveryStage(DynamicDiscoveryStage.ContractParsing, services::recordSupportDiagnostic) { dynamicJson.parseToJsonElement(acquired.document) as? JsonObject - }.getOrNull() ?: return sameOrigin.withUnusableAcquiredContract( - "The acquired App Store contract contained invalid JSON.", - ) + }.getOrNull() ?: return sameOrigin.withUnusableAcquiredContract(services, contractRequest, UnusableContractStage.Parsing) val verifiedReadRouteCount = (document["x-nextcloud-native-verified-read-route-count"] as? JsonPrimitive) ?.contentOrNull ?.toIntOrNull() @@ -215,9 +214,7 @@ suspend fun discoverDynamicAppDescriptor( ), ).requireValid() }.getOrElse { - return sameOrigin.withUnusableAcquiredContract( - "The acquired App Store contract could not be compiled into a native read surface.", - ) + return sameOrigin.withUnusableAcquiredContract(services, contractRequest, UnusableContractStage.Compilation) } return DynamicDescriptorDiscovery( descriptor = descriptor, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index d79fce307..df8d75f09 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -122,14 +122,14 @@ internal suspend fun discoverDynamicAppDescriptor( ) val path = "$OCS_API_VIEWER_SPEC_PATH/${specId.encodeUrlComponent()}" val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(path)) } - .onFailure { failure -> + .onFailure { probe.record(DynamicServerProbeFailure.NoResponse) - diagnostics += "OCS API Viewer could not load $specId: ${failure.message ?: "request failed"}." + diagnostics += "OCS API Viewer could not load $specId (stage: spec, ${DynamicServerProbeFailure.NoResponse.name})." } .getOrNull() ?: return@forEach if (response.status !in 200..299) { probe.record(response) - diagnostics += "OCS API Viewer could not load $specId (HTTP ${response.status})." + diagnostics += "OCS API Viewer could not load $specId (stage: spec, ${response.statusClass()})." return@forEach } val document = response.parseOpenApiDocument() @@ -147,10 +147,9 @@ internal suspend fun discoverDynamicAppDescriptor( ) val descriptor = runCatchingPreservingCancellation { compileDynamicDescriptor(compiler, identity, policy, serverUrl, path, document) - }.onFailure { failure -> + }.onFailure { probe.record(DynamicServerProbeFailure.UnsupportedContract) - diagnostics += "OCS API Viewer returned an unusable specification for $specId: " + - (failure.message ?: "compilation failed") + "." + diagnostics += "OCS API Viewer returned an unusable specification for $specId (stage: compilation)." }.getOrNull() ?: return@forEach return DynamicDescriptorDiscovery( descriptor = descriptor, @@ -241,20 +240,19 @@ private suspend fun discoverOfficialViewerSpecIds( probe: DynamicServerProbe, ): List { val response = runCatchingPreservingCancellation { execute(dynamicDiscoveryRequest(OCS_API_VIEWER_CATALOG_PATH)) } - .onFailure { failure -> + .onFailure { probe.record(DynamicServerProbeFailure.NoResponse) - diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint: " + - (failure.message ?: "request failed") + "." + diagnostics += "OCS API Viewer is unavailable (stage: catalog, ${DynamicServerProbeFailure.NoResponse.name})." } .getOrNull() ?: return emptyList() if (response.status == 404) { codes += DynamicDiagnosticCode.ApiViewerUnavailable - diagnostics += "OCS API Viewer is not installed or enabled for this user (HTTP 404 at $OCS_API_VIEWER_CATALOG_PATH)." + diagnostics += "OCS API Viewer is not installed or enabled for this user (stage: catalog, HTTP 4xx)." return emptyList() } if (response.status !in 200..299) { probe.record(response) - diagnostics += "OCS API Viewer is unavailable at its official authenticated endpoint (HTTP ${response.status})." + diagnostics += "OCS API Viewer is unavailable (stage: catalog, ${response.statusClass()})." return emptyList() } val catalog = runCatchingPreservingCancellation { dynamicJson.parseToJsonElement(response.body.decodeToString()) as? JsonArray } @@ -313,8 +311,15 @@ private fun NextcloudApiResponse.contractBodyFailure(): DynamicServerProbeFailur } } -private fun NextcloudApiResponse.contentTypeDiagnostic(): String = - contentType?.let { " (Content-Type: $it)" } ?: " (without a Content-Type header)" +/** Only the status class enters diagnostics; exact statuses and header values stay out. */ +private fun NextcloudApiResponse.statusClass(): String = "HTTP ${status / 100}xx" + +/** A bounded media-type token; arbitrary server header text never enters diagnostics. */ +private fun NextcloudApiResponse.contentTypeDiagnostic(): String { + val mediaType = contentType?.substringBefore(';')?.trim()?.lowercase() + ?.takeIf { value -> value.length <= 64 && value.all { it.isLetterOrDigit() || it in "/.+-" } } + return " (stage: body, media type ${mediaType ?: "unavailable"})" +} private fun compileDynamicDescriptor( compiler: DynamicAppDescriptorCompiler, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index f15f98e02..cf664a7dd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -5031,7 +5031,7 @@ class DesktopNextcloudServices( } override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = - withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer::acquireOutcome) } + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer) } override suspend fun listActivities(session: NextcloudSession, limit: Int): List = withContext(Dispatchers.IO) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt index 6bb391eec..ff54e04ce 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/AppStoreCatalogRetryTest.kt @@ -13,9 +13,9 @@ class AppStoreCatalogRetryTest { @Test fun theFirstDiscoveryUsesTheCachedCatalogAndAnExplicitRetryRefreshesIt() = runBlocking { for ((attempt, expected) in listOf( - 0 to AppStoreCatalogPolicy.UseCache, - 1 to AppStoreCatalogPolicy.RefreshCatalog, - 3 to AppStoreCatalogPolicy.RefreshCatalog, + 0 to AppStoreCachePolicy.UseCache, + 1 to AppStoreCachePolicy.Refresh, + 3 to AppStoreCachePolicy.Refresh, )) { val requests = mutableListOf() discoverDynamicAppDescriptor( @@ -33,14 +33,51 @@ class AppStoreCatalogRetryTest { } } + @Test + fun anUnusableAcquiredContractDiscardsExactlyThatCachedContract() = runBlocking { + for (attempt in listOf(0, 2)) { + val requests = mutableListOf() + val discovery = discoverDynamicAppDescriptor( + services = services(requests, AppStoreContractAcquisition.Acquired(unreadableContract())).forDiscoveryAttempt(attempt), + session = session, + app = app, + serverVersion = "34.0.3", + installedAppVersionHint = "1.0.0", + ) + assertEquals(DynamicContractFallbackReason.UnsupportedContract, discovery.fallbackReason) + val first = if (attempt == 0) AppStoreCachePolicy.UseCache else AppStoreCachePolicy.Refresh + // A retry decorator never turns the discard request into a refresh. + assertEquals( + listOf(first, AppStoreCachePolicy.DiscardCachedContract), + requests.map(AppStoreContractRequest::cachePolicy), + "attempt $attempt", + ) + assertEquals(setOf(AppStoreContractRequest("example", "34.0.3", "1.0.0")), requests.map { it.copy(cachePolicy = AppStoreCachePolicy.UseCache) }.toSet()) + } + } + @Test fun theCachedPolicyKeepsTheOriginalServices() { val services = services(mutableListOf()) assertSame(services, services.forDiscoveryAttempt(0)) - assertSame(services, services.withAppStoreCatalogPolicy(AppStoreCatalogPolicy.UseCache)) + assertSame(services, services.withAppStoreCachePolicy(AppStoreCachePolicy.UseCache)) } - private fun services(requests: MutableList): NextcloudPlatformServices { + private fun unreadableContract() = AcquiredOpenApiContract( + appId = "example", + appVersion = "1.0.0", + contractVersion = "1.0.0", + specFile = "openapi.json", + document = "not json", + packageUrl = "https://fixture.invalid/example.tar.gz", + sourceUrl = "https://fixture.invalid/example.tar.gz#openapi.json", + sourceKind = AcquiredOpenApiContractSourceKind.SignedAppPackage, + ) + + private fun services( + requests: MutableList, + result: AppStoreContractAcquisition = AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoMatchingRelease), + ): NextcloudPlatformServices { val unavailable = Proxy.newProxyInstance( NextcloudPlatformServices::class.java.classLoader, arrayOf(NextcloudPlatformServices::class.java), @@ -53,7 +90,7 @@ class AppStoreCatalogRetryTest { override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition { requests += request - return AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoMatchingRelease) + return result } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index c3539bbb8..721616398 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -56,6 +56,7 @@ class DynamicContractFallbackDiscoveryTest { AppStoreContractUnavailableReason.NoUsableContract to DynamicContractFallbackReason.NoVerifiedContract, AppStoreContractUnavailableReason.MalformedSource to DynamicContractFallbackReason.MalformedSource, AppStoreContractUnavailableReason.RecentlyChecked to DynamicContractFallbackReason.RecentlyChecked, + AppStoreContractUnavailableReason.UnreadableCatalog to DynamicContractFallbackReason.UnreadableCatalog, ) assertEquals(AppStoreContractUnavailableReason.entries.toSet(), expected.keys) for ((unavailable, reason) in expected) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnosticPrivacyTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnosticPrivacyTest.kt new file mode 100644 index 000000000..4d2c84f17 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnosticPrivacyTest.kt @@ -0,0 +1,98 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DynamicDiscoveryDiagnosticPrivacyTest { + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + @Test + fun aFailedCatalogRequestKeepsNoExceptionText() = runBlocking { + val discovery = discover { request -> + if (request.relativePath == CATALOG) throw IllegalStateException(SECRET_MESSAGE) + response(404) + } + assertPrivate(discovery) + assertTrue("OCS API Viewer is unavailable (stage: catalog, NoResponse)." in discovery.diagnostics) + } + + @Test + fun aFailedSpecRequestKeepsNoExceptionText() = runBlocking { + val discovery = discover { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":"example","specs":["example"]}]""") + "$CATALOG/example" -> throw IllegalStateException(SECRET_MESSAGE) + else -> response(404) + } + } + assertPrivate(discovery) + assertTrue("OCS API Viewer could not load example (stage: spec, NoResponse)." in discovery.diagnostics) + } + + @Test + fun aSpecStatusIsReducedToItsClass() = runBlocking { + val discovery = discover { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":"example","specs":["example"]}]""") + "$CATALOG/example" -> response(503) + else -> response(404) + } + } + assertTrue("OCS API Viewer could not load example (stage: spec, HTTP 5xx)." in discovery.diagnostics) + } + + @Test + fun anUncompilableSpecKeepsNoCompilerMessage() = runBlocking { + val foreignSpec = """{"openapi":"3.0.3","info":{"title":"x","version":"1"},""" + + """"servers":[{"url":"https://evil.invalid/apps/example?token=abc123"}],""" + + """"paths":{"/items":{"get":{"operationId":"list","responses":{"200":{"description":"ok"}}}}}}""" + val discovery = discover { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":"example","specs":["example"]}]""") + "$CATALOG/example" -> response(200, foreignSpec) + else -> response(404) + } + } + assertEquals(DynamicContractFallbackReason.UnsupportedContract, discovery.fallbackReason) + assertTrue("OCS API Viewer returned an unusable specification for example (stage: compilation)." in discovery.diagnostics) + assertPrivate(discovery) + } + + @Test + fun aServerContentTypeIsReducedToABoundedMediaType() = runBlocking { + for ((contentType, expected) in listOf( + "application/json; token=abc123" to "media type application/json", + "text/html" to "media type unavailable", + )) { + val discovery = discover { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":"example","specs":["example"]}]""") + "$CATALOG/example" -> NextcloudApiResponse(200, "{".encodeToByteArray(), contentType, etag = null) + else -> response(404) + } + } + assertTrue(discovery.diagnostics.any { expected in it }, contentType) + assertPrivate(discovery) + } + } + + private fun assertPrivate(discovery: DynamicDescriptorDiscovery) { + val text = discovery.diagnostics.joinToString("\n") + for (secret in listOf("evil.invalid", "secret.invalid", "abc123", "token", "IllegalState", "/private/path", "https://")) { + assertTrue(secret !in text, "Diagnostics exposed $secret: $text") + } + } + + private suspend fun discover(execute: suspend (NextcloudApiRequest) -> NextcloudApiResponse) = + discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = execute) + + private companion object { + const val CATALOG = "/index.php/apps/ocs_api_viewer/apps" + const val SECRET_MESSAGE = "Failed to connect to https://secret.invalid/private/path?token=abc123" + + fun response(status: Int, body: String = ""): NextcloudApiResponse = + NextcloudApiResponse(status, body.encodeToByteArray(), "application/json", etag = null) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index dacde58c6..613eac859 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -1,9 +1,11 @@ package dev.obiente.nextcloudnative.app -import dev.obiente.nextcloudnative.contracts.CatalogPolicy +import dev.obiente.nextcloudnative.contracts.AcquisitionCachePolicy import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason +import dev.obiente.nextcloudnative.contracts.MemoryVerifiedContractCache +import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequestException import dev.obiente.nextcloudnative.contracts.ContractSourceHttpException import dev.obiente.nextcloudnative.contracts.ContractSourceTooLargeException @@ -56,6 +58,7 @@ class JvmAppStoreContractAcquisitionTest { ContractUnavailableReason.NoUsableContract to AppStoreContractUnavailableReason.NoUsableContract, ContractUnavailableReason.MalformedSource to AppStoreContractUnavailableReason.MalformedSource, ContractUnavailableReason.RecentlyChecked to AppStoreContractUnavailableReason.RecentlyChecked, + ContractUnavailableReason.UnreadableCatalog to AppStoreContractUnavailableReason.UnreadableCatalog, )) { assertEquals( AppStoreContractAcquisition.Unavailable(expected), @@ -108,18 +111,40 @@ class JvmAppStoreContractAcquisitionTest { @Test fun theRequestAndItsCatalogPolicyCrossTheBoundaryUnchanged() { for ((policy, expected) in listOf( - AppStoreCatalogPolicy.UseCache to CatalogPolicy.UseCache, - AppStoreCatalogPolicy.RefreshCatalog to CatalogPolicy.Refresh, + AppStoreCachePolicy.UseCache to AcquisitionCachePolicy.UseCache, + AppStoreCachePolicy.Refresh to AcquisitionCachePolicy.Refresh, )) { - var seen: Pair? = null - acquireAppStoreContract(request.copy(catalogPolicy = policy)) { contractRequest, catalogPolicy -> - seen = contractRequest to catalogPolicy + var seen: Pair? = null + acquireAppStoreContract(request.copy(cachePolicy = policy)) { contractRequest, cachePolicy -> + seen = contractRequest to cachePolicy ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoMatchingRelease) } assertEquals(ContractAcquisitionRequest("example", "34.0.3", "1.0.0") to expected, seen) } } + @Test + fun discardingForgetsOnlyTheExactCachedContract() { + val cache = MemoryVerifiedContractCache() + val exact = ContractAcquisitionRequest("example", "34.0.3", "1.0.0") + val other = ContractAcquisitionRequest("example", "34.0.3", "1.0.1") + val contract = verified(OpenApiContractSourceKind.SignedAppPackage, VerifiedContractKind.OpenApi) + cache.store(exact, contract) + cache.store(other, contract) + val outcome = acquireAppStoreContract( + request.copy(cachePolicy = AppStoreCachePolicy.DiscardCachedContract), + SignedAppStoreContractAcquirer(verifiedContractCache = cache), + ) + assertEquals(AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract), outcome) + assertEquals(null, cache.load(exact)) + assertEquals(contract, cache.load(other)) + assertFailsWith { + acquireAppStoreContract(request.copy(cachePolicy = AppStoreCachePolicy.DiscardCachedContract)) { _, _ -> + error("A discard must not acquire.") + } + } + } + private fun verified(source: OpenApiContractSourceKind, kind: VerifiedContractKind) = VerifiedOpenApiContract( appId = "example", appVersion = "1.0.1", diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index 2b5764cdc..1b9641ac0 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -1,10 +1,11 @@ package dev.obiente.nextcloudnative.app -import dev.obiente.nextcloudnative.contracts.CatalogPolicy +import dev.obiente.nextcloudnative.contracts.AcquisitionCachePolicy import dev.obiente.nextcloudnative.contracts.ContractAcquisitionFailureKind import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason +import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract @@ -21,14 +22,27 @@ import kotlinx.coroutines.CancellationException */ fun acquireAppStoreContract( request: AppStoreContractRequest, - acquire: (ContractAcquisitionRequest, CatalogPolicy) -> ContractAcquisitionOutcome, + acquirer: SignedAppStoreContractAcquirer, ): AppStoreContractAcquisition { - val catalogPolicy = when (request.catalogPolicy) { - AppStoreCatalogPolicy.UseCache -> CatalogPolicy.UseCache - AppStoreCatalogPolicy.RefreshCatalog -> CatalogPolicy.Refresh + if (request.cachePolicy != AppStoreCachePolicy.DiscardCachedContract) { + return acquireAppStoreContract(request, acquirer::acquireOutcome) + } + acquirer.invalidateVerifiedContract(request.toContractAcquisitionRequest()) + return AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) +} + +fun acquireAppStoreContract( + request: AppStoreContractRequest, + acquire: (ContractAcquisitionRequest, AcquisitionCachePolicy) -> ContractAcquisitionOutcome, +): AppStoreContractAcquisition { + val cachePolicy = when (request.cachePolicy) { + AppStoreCachePolicy.UseCache -> AcquisitionCachePolicy.UseCache + AppStoreCachePolicy.Refresh -> AcquisitionCachePolicy.Refresh + AppStoreCachePolicy.DiscardCachedContract -> + throw IllegalArgumentException("Discarding a cached contract needs the acquirer, not an acquisition.") } val outcome = try { - acquire(ContractAcquisitionRequest(request.appId, request.serverVersion, request.installedAppVersion), catalogPolicy) + acquire(request.toContractAcquisitionRequest(), cachePolicy) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { @@ -48,9 +62,13 @@ private fun ContractUnavailableReason.toAppStoreReason(): AppStoreContractUnavai AppStoreContractUnavailableReason.SourceTemporarilyUnavailable ContractUnavailableReason.NoUsableContract -> AppStoreContractUnavailableReason.NoUsableContract ContractUnavailableReason.MalformedSource -> AppStoreContractUnavailableReason.MalformedSource + ContractUnavailableReason.UnreadableCatalog -> AppStoreContractUnavailableReason.UnreadableCatalog ContractUnavailableReason.RecentlyChecked -> AppStoreContractUnavailableReason.RecentlyChecked } +private fun AppStoreContractRequest.toContractAcquisitionRequest() = + ContractAcquisitionRequest(appId, serverVersion, installedAppVersion) + private fun ContractAcquisitionFailureKind.toAppStoreKind(): AppStoreContractFailureKind = when (this) { ContractAcquisitionFailureKind.Network -> AppStoreContractFailureKind.Network ContractAcquisitionFailureKind.SourceUnavailable -> AppStoreContractFailureKind.SourceUnavailable From 0a5eda03a3f5ad305e395cd0e6f898e2ad9c758f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 08:08:02 +0200 Subject: [PATCH 31/49] test(android): initialize the contract outcome facade on the Android runtime --- .../nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 96e049219..40a9c79da 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -56,7 +56,7 @@ class AppOwnedOpenApiAndroidInstrumentedTest { // unit test ContractModuleInitializationTest fails when a facade is missing from this list. listOf( "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "AppStoreCatalogReleasesKt", - "ContractAcquisitionFailureKt", "LinkedSourceArchiveKt", + "ContractAcquisitionFailureKt", "ContractAcquisitionOutcomeKt", "LinkedSourceArchiveKt", "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", "SignedPackageTarReaderKt", "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", From 6ee6e6b03774f9783ac0c341dad005d64ec0d8f8 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:23:51 +0000 Subject: [PATCH 32/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index d209ccb72..ec3ec01b3 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,7 +585,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "8d3c7ea1ae4cf577c71f7e318c00e35e567d1715d2543278bdcb973126b355fa", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "5c9bf75cfa09c5e53674974e08c207cdd34268c311b57a2b1b5a289f857153c2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "c24734ae869fb6fa144c257961c04285e24de9209761ea3cacc99a9208ca118a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "d79ec4f9fbd8fac36e66af7fc692941f81f2754bc873de417c0ee60b4c210403", @@ -596,11 +596,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "e9899cc16a75c8a6b5a4d4cfb1c4d273f93dcad9845327862528dfdb3c38682d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "aeff6362c02d1b75f714a912d768cb481e40a1c5aa62b7361d773ad9dceb9478", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "0075deccd3f651b1fd43a21b0d2bdaded4ba92f122265844b77f5a694030f0bd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "d2080770aa8dc84e25b903980362b28cde54de11911b5970cfd482fe5b7f6687", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", From aa4acb3603573d247cb6390d1663abd8fb2f94e5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 09:02:29 +0200 Subject: [PATCH 33/49] fix(contracts): never serve a cached contract that could not be deleted When discarding an unusable verified contract fails to delete its file, the cache writes an invalidation marker beside it, or, if that also fails, records the invalidation for the life of the process. Reads treat either as a miss and retry the deletion, and a newly stored contract clears it. The shared JVM boundary records a bounded VERIFIED_CONTRACT_DELETION_FAILED diagnostic naming only the typed removal result. --- DYNAMIC_APP_DESCRIPTOR.md | 7 +- .../AndroidNextcloudServices.kt | 2 +- .../SignedAppStoreContractAcquirer.kt | 4 +- .../contracts/VerifiedContractCache.kt | 99 +++++++++++- .../contracts/VerifiedContractCacheTest.kt | 2 +- .../VerifiedContractInvalidationTest.kt | 149 ++++++++++++++++++ .../app/DesktopNextcloudServices.kt | 2 +- .../app/JvmAppStoreContractAcquisitionTest.kt | 29 ++++ .../app/JvmAppStoreContractAcquisition.kt | 15 +- 9 files changed, 294 insertions(+), 15 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractInvalidationTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index b30da54fb..faf961ab0 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -268,7 +268,11 @@ failed refresh leaves it in place. A refresh refused by the window serves the cached contract when one exists. When an acquired contract cannot be parsed or compiled, discovery discards the cached contract for exactly that app, server version, and installed version, so the next automatic discovery acquires it -again; other cached contracts stay. +again; other cached contracts stay. If the cached file cannot be deleted, an +invalidation marker beside it, or, when that also fails, a record kept for the +life of the process, makes every later read a miss, and each later read or +store retries the deletion. A `VERIFIED_CONTRACT_DELETION_FAILED` warning +diagnostic records which of the two applied, with no path or exception text. [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. @@ -332,6 +336,7 @@ only genuine client faults remain unexpected. Size limits throw | Remaining unexpected failures | XML parser configuration, internal invariants | Client fault | ## Read failures +| Verified contract cache discard | The cached file cannot be deleted | Marked invalid, or invalidated for the process; never served again | [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) classifies unsuccessful HTTP reads as authentication, permission, missing diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 3ebabed86..f92cef5b3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2957,7 +2957,7 @@ internal class AndroidNextcloudServices( } override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = - withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer) } + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer, ::recordSupportDiagnostic) } override suspend fun listActivities( session: NextcloudSession, diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index f5ab02085..461d2ecc9 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -182,9 +182,9 @@ class SignedAppStoreContractAcquirer( } /** Forgets the verified contract for [request], for example after it proved unusable downstream. */ - fun invalidateVerifiedContract(request: ContractAcquisitionRequest) { + fun invalidateVerifiedContract(request: ContractAcquisitionRequest): VerifiedContractRemoval { verifiedContracts.remove(request) - verifiedContractCache.remove(request) + return verifiedContractCache.remove(request) } private fun VerifiedPackageContract.asPackageContract( diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt index 4a2082a3a..9fecf1808 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCache.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.contracts import java.io.File +import java.io.IOException import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import org.json.JSONObject @@ -16,8 +17,26 @@ interface VerifiedContractCache { fun load(request: ContractAcquisitionRequest): VerifiedOpenApiContract? fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) - /** Forgets only the contract cached for [request]; other entries stay. */ - fun remove(request: ContractAcquisitionRequest) + /** + * Forgets only the contract cached for [request]; other entries stay. A discarded entry is never + * served again, even when it cannot be deleted; the result says how that was ensured. + */ + fun remove(request: ContractAcquisitionRequest): VerifiedContractRemoval +} + +/** How a cached verified contract was discarded. Bounded and typed, for local diagnostics. */ +enum class VerifiedContractRemoval { + /** The entry was deleted, or nothing was cached. */ + Removed, + + /** The entry could not be deleted; an invalidation marker beside it makes every read a miss. */ + MarkedInvalid, + + /** + * Neither deletion nor the marker succeeded; reads in this process treat the entry as a miss, + * and deletion is retried on each later read or store. + */ + InvalidatedForProcess, } class MemoryVerifiedContractCache : VerifiedContractCache { @@ -29,8 +48,9 @@ class MemoryVerifiedContractCache : VerifiedContractCache { entries[request] = contract } - override fun remove(request: ContractAcquisitionRequest) { + override fun remove(request: ContractAcquisitionRequest): VerifiedContractRemoval { entries.remove(request) + return VerifiedContractRemoval.Removed } } @@ -42,12 +62,23 @@ class MemoryVerifiedContractCache : VerifiedContractCache { * deterministic. Entries expire after seven days and are keyed with SHA-256 rather than exposing * app or version details in filenames. */ -class FileVerifiedContractCache( +class FileVerifiedContractCache internal constructor( private val directory: File, - private val nowMillis: () -> Long = System::currentTimeMillis, + private val nowMillis: () -> Long, + private val files: VerifiedContractFileOperations, ) : VerifiedContractCache { + constructor(directory: File, nowMillis: () -> Long = System::currentTimeMillis) : + this(directory, nowMillis, VerifiedContractFileOperations.Default) + + /** Entries whose deletion failed while this cache was in use; guarded by [lock]. */ + private val invalidatedForProcess = mutableSetOf() + override fun load(request: ContractAcquisitionRequest): VerifiedOpenApiContract? = synchronized(lock) { val file = cacheFile(request) + if (isInvalidated(file)) { + discard(file) + return null + } if (!file.isFile || nowMillis() - file.lastModified() !in 0..MAX_CACHE_AGE_MILLIS) return null if (file.length() !in 1..MAX_CONTRACT_CACHE_BYTES) return null runCatching { @@ -73,18 +104,47 @@ class FileVerifiedContractCache( output.write(bytes) output.flush() } - check(!target.exists() || target.delete()) { "Could not replace the cached verified contract." } + check(!target.exists() || files.delete(target)) { "Could not replace the cached verified contract." } check(temporary.renameTo(target)) { "Could not publish the cached verified contract." } check(target.setLastModified(nowMillis())) { "Could not timestamp the cached verified contract." } + // A newly verified contract supersedes an earlier invalidation of the same entry. + clearInvalidation(target) } finally { if (temporary.exists()) temporary.delete() } } - override fun remove(request: ContractAcquisitionRequest): Unit = synchronized(lock) { - cacheFile(request).delete() + override fun remove(request: ContractAcquisitionRequest): VerifiedContractRemoval = synchronized(lock) { + val file = cacheFile(request) + if (discard(file)) return VerifiedContractRemoval.Removed + // Deletion failed, so the entry must not be served again until it is gone. + invalidatedForProcess += file.absolutePath + val marker = invalidationMarker(file) + if (marker.exists() || files.createMarker(marker)) { + VerifiedContractRemoval.MarkedInvalid + } else { + VerifiedContractRemoval.InvalidatedForProcess + } + } + + private fun isInvalidated(file: File): Boolean = + file.absolutePath in invalidatedForProcess || invalidationMarker(file).exists() + + /** Deletes [file] and then any invalidation of it. Returns false while the entry still exists. */ + private fun discard(file: File): Boolean { + if (file.exists() && !files.delete(file)) return false + clearInvalidation(file) + return true + } + + /** The process record stays while a marker cannot be removed, so the entry remains a miss. */ + private fun clearInvalidation(file: File) { + val marker = invalidationMarker(file) + if (!marker.exists() || files.delete(marker)) invalidatedForProcess -= file.absolutePath } + private fun invalidationMarker(file: File): File = File(file.parentFile, "${file.name}$INVALIDATION_SUFFIX") + private fun cacheFile(request: ContractAcquisitionRequest): File { val identity = listOf( CACHE_FORMAT_VERSION, @@ -99,12 +159,35 @@ class FileVerifiedContractCache( private companion object { val lock = Any() + const val INVALIDATION_SUFFIX = ".invalid" const val CACHE_FORMAT_VERSION = "4" const val MAX_CONTRACT_CACHE_BYTES = 32L * 1024L * 1024L const val MAX_CACHE_AGE_MILLIS = 7L * 24L * 60L * 60L * 1_000L } } +/** File operations that can fail without throwing; a seam for deletion failures in tests. */ +internal interface VerifiedContractFileOperations { + fun delete(file: File): Boolean + fun createMarker(file: File): Boolean + + object Default : VerifiedContractFileOperations { + override fun delete(file: File): Boolean = try { + file.delete() + } catch (_: SecurityException) { + false + } + + override fun createMarker(file: File): Boolean = try { + file.createNewFile() || file.isFile + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false + } + } +} + private fun VerifiedOpenApiContract.toJson(): JSONObject = JSONObject() .put("appId", appId) .put("appVersion", appVersion) diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt index 976e30464..64adaef81 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractCacheTest.kt @@ -100,7 +100,7 @@ class VerifiedContractCacheTest { override fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) = Unit - override fun remove(request: ContractAcquisitionRequest) = Unit + override fun remove(request: ContractAcquisitionRequest) = VerifiedContractRemoval.Removed } val acquirer = SignedAppStoreContractAcquirer(verifiedContractCache = cache) val executor = Executors.newFixedThreadPool(4) diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractInvalidationTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractInvalidationTest.kt new file mode 100644 index 000000000..eac253ab4 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/VerifiedContractInvalidationTest.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative.contracts + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class VerifiedContractInvalidationTest { + private val directory: File = Files.createTempDirectory("verified-contract-invalidation").toFile() + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + @AfterTest + fun cleanUp() { + directory.deleteRecursively() + } + + @Test + fun `a successful deletion removes the entry and leaves no marker`() { + val cache = FileVerifiedContractCache(directory) + cache.store(request, contract("{\"version\":1}")) + assertEquals(VerifiedContractRemoval.Removed, cache.remove(request)) + assertNull(cache.load(request)) + assertTrue(directory.listFiles().orEmpty().isEmpty()) + + cache.store(request, contract("{\"version\":2}")) + assertEquals("{\"version\":2}", cache.load(request)?.document) + assertEquals(VerifiedContractRemoval.Removed, cache.remove(ContractAcquisitionRequest("absent", "34.0.3", "1.0.0"))) + } + + @Test + fun `an undeletable entry is marked invalid and never served again, even after a restart`() { + val files = FakeFiles(deletable = false) + val cache = FileVerifiedContractCache(directory, { NOW }, files) + cache.store(request, contract("{\"version\":1}")) + + assertEquals(VerifiedContractRemoval.MarkedInvalid, cache.remove(request)) + assertNull(cache.load(request)) + // A new process without the in-memory record still honors the marker on disk. + assertNull(FileVerifiedContractCache(directory, { NOW }, files).load(request)) + assertEquals(2, directory.listFiles().orEmpty().size) + } + + @Test + fun `an entry that can be neither deleted nor marked is a miss for the rest of the process`() { + val files = FakeFiles(deletable = false, markable = false) + val cache = FileVerifiedContractCache(directory, { NOW }, files) + cache.store(request, contract("{\"version\":1}")) + + assertEquals(VerifiedContractRemoval.InvalidatedForProcess, cache.remove(request)) + assertNull(cache.load(request)) + assertEquals(1, directory.listFiles().orEmpty().size) + + // The next read retries the deletion; once it succeeds, a newly stored contract is served. + files.deletable = true + assertNull(cache.load(request)) + assertTrue(directory.listFiles().orEmpty().isEmpty()) + cache.store(request, contract("{\"version\":2}")) + assertEquals("{\"version\":2}", cache.load(request)?.document) + } + + @Test + fun `a newly stored contract clears an earlier marker`() { + val files = FakeFiles(deletable = false) + val cache = FileVerifiedContractCache(directory, { NOW }, files) + cache.store(request, contract("{\"version\":1}")) + assertEquals(VerifiedContractRemoval.MarkedInvalid, cache.remove(request)) + + files.deletable = true + cache.store(request, contract("{\"version\":2}")) + assertEquals("{\"version\":2}", cache.load(request)?.document) + assertEquals(1, directory.listFiles().orEmpty().size) + } + + @Test + fun `automatic discovery acquires again after an undeletable contract is invalidated`() { + MockWebServer().use { server -> + server.start() + val cache = FileVerifiedContractCache(directory, { NOW }, FakeFiles(deletable = false)) + val acquirer = acquirer(server, cache) + packageDocument = "{\"version\":1}" + enqueueCatalogAndPackage(server) + assertEquals("{\"version\":1}", document(acquirer.acquireOutcome(request))) + + assertEquals(VerifiedContractRemoval.MarkedInvalid, acquirer.invalidateVerifiedContract(request)) + packageDocument = "{\"version\":2}" + server.enqueue(MockResponse(body = "package")) + // The catalog stays cached; the package is downloaded again instead of serving the invalid entry. + assertEquals("{\"version\":2}", document(acquirer.acquireOutcome(request))) + assertEquals(3, server.requestCount) + // The replacement could not be written over the undeletable file, so the disk entry stays a miss. + assertNull(cache.load(request)) + } + } + + private var packageDocument = "{\"version\":1}" + + private class FakeFiles(var deletable: Boolean, val markable: Boolean = true) : VerifiedContractFileOperations { + override fun delete(file: File): Boolean = deletable && file.delete() + + override fun createMarker(file: File): Boolean = markable && file.createNewFile() + } + + private fun contract(document: String) = VerifiedOpenApiContract( + appId = "cospend", + appVersion = "4.0.2", + contractVersion = "4.0.2", + specFile = "openapi.json", + document = document, + catalogUrl = "https://fixture.invalid/apps.json", + packageUrl = "https://fixture.invalid/cospend.tar.gz", + sourceUrl = "https://fixture.invalid/cospend.tar.gz#openapi.json", + sourceKind = OpenApiContractSourceKind.SignedAppPackage, + contractKind = VerifiedContractKind.OpenApi, + ) + + private fun document(outcome: ContractAcquisitionOutcome): String = + assertIs(outcome).contract.document + + private fun enqueueCatalogAndPackage(server: MockWebServer) { + val download = server.url("downloads/cospend-4.0.2.tar.gz") + server.enqueue(MockResponse(body = """[{"id":"cospend","certificate":"c","releases":[""" + + """{"version":"4.0.2","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""")) + server.enqueue(MockResponse(body = "package")) + } + + private fun acquirer(server: MockWebServer, cache: VerifiedContractCache) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = + VerifiedPackageContract(release.appId, release.version, "openapi.json", packageDocument) + }, + verifiedContractCache = cache, + catalogRefreshThrottle = CatalogRefreshThrottle(nowMillis = { NOW }), + ) + + private companion object { + const val NOW = 50_000_000L + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index cf664a7dd..dbfb4980b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -5031,7 +5031,7 @@ class DesktopNextcloudServices( } override suspend fun acquireSignedOpenApiContract(request: AppStoreContractRequest): AppStoreContractAcquisition = - withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer) } + withContext(Dispatchers.IO) { acquireAppStoreContract(request, contractAcquirer, ::recordSupportDiagnostic) } override suspend fun listActivities(session: NextcloudSession, limit: Int): List = withContext(Dispatchers.IO) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt index 613eac859..f1bc2ff95 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisitionTest.kt @@ -11,7 +11,9 @@ import dev.obiente.nextcloudnative.contracts.ContractSourceHttpException import dev.obiente.nextcloudnative.contracts.ContractSourceTooLargeException import dev.obiente.nextcloudnative.contracts.ContractSourceVerificationException import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind +import dev.obiente.nextcloudnative.contracts.VerifiedContractCache import dev.obiente.nextcloudnative.contracts.VerifiedContractKind +import dev.obiente.nextcloudnative.contracts.VerifiedContractRemoval import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract import java.net.SocketTimeoutException import kotlinx.coroutines.CancellationException @@ -131,11 +133,14 @@ class JvmAppStoreContractAcquisitionTest { val contract = verified(OpenApiContractSourceKind.SignedAppPackage, VerifiedContractKind.OpenApi) cache.store(exact, contract) cache.store(other, contract) + val diagnostics = mutableListOf() val outcome = acquireAppStoreContract( request.copy(cachePolicy = AppStoreCachePolicy.DiscardCachedContract), SignedAppStoreContractAcquirer(verifiedContractCache = cache), + diagnostics::add, ) assertEquals(AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract), outcome) + assertEquals(emptyList(), diagnostics) assertEquals(null, cache.load(exact)) assertEquals(contract, cache.load(other)) assertFailsWith { @@ -145,6 +150,30 @@ class JvmAppStoreContractAcquisitionTest { } } + @Test + fun anUndeletableCachedContractIsRecordedAsATypedDiagnostic() { + for (removal in listOf(VerifiedContractRemoval.MarkedInvalid, VerifiedContractRemoval.InvalidatedForProcess)) { + val cache = object : VerifiedContractCache { + override fun load(request: ContractAcquisitionRequest) = null + override fun store(request: ContractAcquisitionRequest, contract: VerifiedOpenApiContract) = Unit + override fun remove(request: ContractAcquisitionRequest) = removal + } + val diagnostics = mutableListOf() + val outcome = acquireAppStoreContract( + request.copy(cachePolicy = AppStoreCachePolicy.DiscardCachedContract), + SignedAppStoreContractAcquirer(verifiedContractCache = cache), + diagnostics::add, + ) + assertEquals(AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract), outcome) + val diagnostic = diagnostics.single() + assertEquals(SupportDiagnosticComponent.Cache, diagnostic.component) + assertEquals("VERIFIED_CONTRACT_DELETION_FAILED", diagnostic.code) + assertEquals(listOf(SupportDiagnosticFieldDraft("removal", removal.name)), diagnostic.fields) + assertEquals(null, diagnostic.message) + assertEquals(null, diagnostic.exception) + } + } + private fun verified(source: OpenApiContractSourceKind, kind: VerifiedContractKind) = VerifiedOpenApiContract( appId = "example", appVersion = "1.0.1", diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt index 1b9641ac0..337ea5717 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmAppStoreContractAcquisition.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.contracts.ContractAcquisitionOutcome import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest import dev.obiente.nextcloudnative.contracts.ContractUnavailableReason import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer +import dev.obiente.nextcloudnative.contracts.VerifiedContractRemoval import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import dev.obiente.nextcloudnative.contracts.VerifiedOpenApiContract @@ -23,14 +24,26 @@ import kotlinx.coroutines.CancellationException fun acquireAppStoreContract( request: AppStoreContractRequest, acquirer: SignedAppStoreContractAcquirer, + onDiagnostic: (SupportDiagnosticEventDraft) -> Unit = {}, ): AppStoreContractAcquisition { if (request.cachePolicy != AppStoreCachePolicy.DiscardCachedContract) { return acquireAppStoreContract(request, acquirer::acquireOutcome) } - acquirer.invalidateVerifiedContract(request.toContractAcquisitionRequest()) + val removal = acquirer.invalidateVerifiedContract(request.toContractAcquisitionRequest()) + if (removal != VerifiedContractRemoval.Removed) onDiagnostic(verifiedContractDeletionFailure(removal)) return AppStoreContractAcquisition.Unavailable(AppStoreContractUnavailableReason.NoUsableContract) } +/** A cached contract that could not be deleted stays invalidated; only the typed removal is recorded. */ +private fun verifiedContractDeletionFailure(removal: VerifiedContractRemoval) = SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Cache, + operation = "contract.cache.discard", + outcome = "deletion_failed", + code = "VERIFIED_CONTRACT_DELETION_FAILED", + fields = listOf(SupportDiagnosticFieldDraft("removal", removal.name)), +) + fun acquireAppStoreContract( request: AppStoreContractRequest, acquire: (ContractAcquisitionRequest, AcquisitionCachePolicy) -> ContractAcquisitionOutcome, From dca1e2ccde451074c103ff6e10ecedd864785ec4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 09:02:36 +0200 Subject: [PATCH 34/49] fix(contracts): reject catalog and contract values of the wrong JSON type Required App Store catalog strings (id, certificate, version, download, signature, signatureDigest) must be JSON strings, and isNightly must be a boolean when present. Any other type drops the entry with a typed malformed field instead of being coerced by optString or optBoolean, so a matching entry reports MalformedSource. Contract document reads use the same strict accessors: a non-string openapi version, operation ID, parameter, or reference is ignored, a non-boolean required flag fails closed, and a GitHub tree whose truncated flag is not false is not trusted. --- DYNAMIC_APP_DESCRIPTOR.md | 11 +- .../AppOwnedOpenApiAndroidInstrumentedTest.kt | 6 +- .../contracts/AppOwnedOpenApiContract.kt | 2 +- .../contracts/AppStoreCatalogReleases.kt | 35 +++-- .../SignedAppStoreContractAcquirer.kt | 14 +- .../contracts/StaticRouteContract.kt | 22 +-- .../contracts/StrictJsonValues.kt | 20 +++ .../contracts/CatalogFieldTypeTest.kt | 131 ++++++++++++++++++ .../contracts/StrictJsonValuesTest.kt | 36 +++++ 9 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValues.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogFieldTypeTest.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValuesTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index faf961ab0..83085d6d8 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -197,7 +197,7 @@ The fallback card derives its title, explanation, and action from that reason: - the App Store lists no release matching the installed version; - the App Store lists the app, but the entry that would be selected lacks a required value such as its certificate, download, signature, or signature - digest; + digest, or supplies one with the wrong JSON type; - an App Store catalog document could not be read at all, so no entries were available to check; - the app's linked source refused or rate limited the last fallback request @@ -241,6 +241,11 @@ separates no matching release, a malformed catalog entry, a temporarily unavailable linked source, and releases inspected without a usable contract, and the shared JVM boundary carries that reason to the fallback card. Catalog parsing records which required value made each dropped entry unusable. A +required value must have its exact JSON type: a number, boolean, object, array, +or `null` where a string belongs, or an `isNightly` that is not a boolean, is +malformed like a missing value and is never coerced. Contract documents are read +the same way, so a non-string `openapi` version, operation ID, parameter, or +reference is ignored, and a non-boolean `required` flag counts as required. A dropped entry counts only when it would have been selected: the exact installed version, a patch-compatible release, or an entry whose version cannot be read. A catalog document that is truncated, empty, not JSON, or not an array is an @@ -319,7 +324,7 @@ only genuine client faults remain unexpected. Size limits throw | Catalog and package requests | No response; non-success status; redirect away from HTTPS | Network; source unavailable; verification failed | | Catalog and package bodies | Declared or streamed size over the limit | Source unusable | | Catalog document | Truncated, empty, not JSON, or not an array | Unreadable catalog, never cached | -| Catalog entry | Missing value, or a download that is not an HTTP URL | Malformed source when it would be selected | +| Catalog entry | Missing value, a required value of the wrong JSON type, or a download that is not an HTTP URL | Malformed source when it would be selected | | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | | Package verification | Signature, certificate, revocation, identity, archive structure, or app metadata rejected | Verification failed | @@ -331,12 +336,12 @@ only genuine client faults remain unexpected. Size limits throw | Linked archive content | Corrupt, unsafe path, identity mismatch, malformed or misencoded `info.xml`, size limit | No candidate | | Linked JSON or YAML documents | Unreadable | Skipped | | Acquired contract | Unreadable JSON or a descriptor that cannot be compiled | Unsupported contract; that cached contract is discarded | +| Verified contract cache discard | The cached file cannot be deleted | Marked invalid, or invalidated for the process; never served again | | Contract module class loading | `LinkageError` | Client fault | | Same-origin probes | See the precedence list above | Typed probe result | | Remaining unexpected failures | XML parser configuration, internal invariants | Client fault | ## Read failures -| Verified contract cache discard | The cached file cannot be deleted | Marked invalid, or invalidated for the process; never served again | [`DynamicReadFailure.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt) classifies unsuccessful HTTP reads as authentication, permission, missing diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt index 40a9c79da..b78ed3013 100644 --- a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AppOwnedOpenApiAndroidInstrumentedTest.kt @@ -58,9 +58,9 @@ class AppOwnedOpenApiAndroidInstrumentedTest { "AppOwnedOpenApiContractKt", "AppStoreCatalogCacheKt", "AppStoreCatalogReleasesKt", "ContractAcquisitionFailureKt", "ContractAcquisitionOutcomeKt", "LinkedSourceArchiveKt", "NextcloudAppPackageTrustVerifierKt", "OpenApiYamlParserKt", "SignedAppStoreContractAcquirerKt", - "SignedPackageTarReaderKt", - "StaticPhpParametersKt", "StaticRouteContractKt", "TarArchivePathKt", "VerifiedChoresContractKt", - "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", "SignedAppStoreContractAcquirer", + "SignedPackageTarReaderKt", "StaticPhpParametersKt", "StaticRouteContractKt", "StrictJsonValuesKt", + "TarArchivePathKt", "VerifiedChoresContractKt", "VerifiedContractCacheKt", "VerifiedExactWriteAdaptersKt", + "SignedAppStoreContractAcquirer", "FileAppStoreCatalogCache", "DynamicApiResponseCache", "FileVerifiedContractCache", ).forEach { name -> Class.forName("dev.obiente.nextcloudnative.contracts.$name") } Class.forName("dev.obiente.nextcloudnative.app.JvmAppStoreContractAcquisitionKt") diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppOwnedOpenApiContract.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppOwnedOpenApiContract.kt index b81d94497..53f8bf428 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppOwnedOpenApiContract.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppOwnedOpenApiContract.kt @@ -47,7 +47,7 @@ internal fun isAppOwnedOpenApiDocument( servers != null && (0 until servers.length()).asSequence() .mapNotNull(servers::optJSONObject) - .map { server -> server.optString("url") } + .mapNotNull { server -> server.stringValue("url") } .any { serverUrl -> portableOpenApiServerPath(serverUrl)?.referencesOwnedApp() == true } ) { return true diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 193221d3b..93446886e 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -13,13 +13,20 @@ internal enum class MalformedCatalogField { Releases, Entry, Version, + + /** `isNightly` is present but not a JSON boolean, so the release channel is unknown. */ + Nightly, Certificate, Download, Signature, SignatureDigest, } -/** A catalog entry for the requested app that parsing dropped. A null version could not be read. */ +/** + * A catalog entry for the requested app that parsing dropped. A null version could not be read. A + * required value of the wrong JSON type, for example a number or object where a string belongs, is + * malformed exactly like a missing one; it is never coerced. + */ internal data class MalformedCatalogEntry(val version: String?, val field: MalformedCatalogField) /** Where a catalog read came from. */ @@ -99,11 +106,11 @@ internal fun parseReleases( val app = (0 until apps.length()) .asSequence() .mapNotNull(apps::optJSONObject) - .firstOrNull { candidate -> candidate.optString("id") == appId } + .firstOrNull { candidate -> candidate.stringValue("id") == appId } ?: return CatalogReleases(emptyList()) val releases = app.optJSONArray("releases") ?: return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, MalformedCatalogField.Releases))) - val certificate = app.optString("certificate").takeIf(String::isNotBlank) + val certificate = app.stringValue("certificate")?.takeIf(String::isNotBlank) val malformed = mutableListOf() val parsed = (0 until releases.length()) .asSequence() @@ -112,18 +119,22 @@ internal fun parseReleases( if (release == null) malformed += MalformedCatalogEntry(null, MalformedCatalogField.Entry) } } - .filter { release -> !release.optBoolean("isNightly", false) } + .filter { release -> !release.booleanValue("isNightly", absent = false, wrongType = false) } .mapNotNull { release -> - val version = release.optString("version").takeIf(String::isNotBlank) + val version = release.stringValue("version")?.takeIf(String::isNotBlank) + val nightlyKnown = release.opt("isNightly").let { value -> value == null || value is Boolean } // A download value that is not a valid HTTP URL is malformed catalog data, like a missing one. - val download = release.optString("download").takeIf { value -> value.toHttpUrlOrNull() != null } - val signature = release.optString("signature").takeIf(String::isNotBlank) - val digest = release.optString("signatureDigest").takeIf(String::isNotBlank) - if (version == null || certificate == null || download == null || signature == null || digest == null) { + val download = release.stringValue("download")?.takeIf { value -> value.toHttpUrlOrNull() != null } + val signature = release.stringValue("signature")?.takeIf(String::isNotBlank) + val digest = release.stringValue("signatureDigest")?.takeIf(String::isNotBlank) + if (version == null || !nightlyKnown || certificate == null || download == null || signature == null || + digest == null + ) { malformed += MalformedCatalogEntry( version = version, field = when { version == null -> MalformedCatalogField.Version + !nightlyKnown -> MalformedCatalogField.Nightly certificate == null -> MalformedCatalogField.Certificate download == null -> MalformedCatalogField.Download signature == null -> MalformedCatalogField.Signature @@ -140,7 +151,7 @@ internal fun parseReleases( signatureDigest = digest, certificatePem = certificate, catalogUrl = catalogUrl, - platformVersionSpec = release.optString("platformVersionSpec").takeIf(String::isNotBlank), + platformVersionSpec = release.stringValue("platformVersionSpec")?.takeIf(String::isNotBlank), githubSources = deriveGitHubSources( app = app, downloadUrl = download, @@ -240,8 +251,8 @@ private fun deriveGitHubSources( ): List { val release = parseGitHubReleaseLocation(downloadUrl, requireOfficialReleaseHost) ?: return emptyList() val repositories = buildList { - parseGitHubRepository(app.optString("repository"))?.let(::add) - parseGitHubRepository(app.optString("website"))?.let(::add) + app.stringValue("repository")?.let(::parseGitHubRepository)?.let(::add) + app.stringValue("website")?.let(::parseGitHubRepository)?.let(::add) add(release.repository) }.distinct() val conventionalTags = setOf(releaseVersion, "v$releaseVersion") diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 461d2ecc9..7aaa96e2c 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -243,13 +243,13 @@ class SignedAppStoreContractAcquirer( ) val contracts = treeBytes?.let { bytes -> val tree = runCatching { JSONObject(bytes.decodeToString()) }.getOrNull() - val entries = tree?.takeUnless { it.optBoolean("truncated", true) }?.optJSONArray("tree") + val entries = tree?.takeUnless { it.booleanValue("truncated", absent = true, wrongType = true) }?.optJSONArray("tree") entries?.let { treeEntries -> val candidates = (0 until treeEntries.length()) .asSequence() .mapNotNull(treeEntries::optJSONObject) - .filter { entry -> entry.optString("type") == "blob" } - .map { entry -> entry.optString("path") } + .filter { entry -> entry.stringValue("type") == "blob" } + .mapNotNull { entry -> entry.stringValue("path") } .filter { path -> path.substringAfterLast('/').matches(OPEN_API_FILE_PATTERN) } .distinct() .sorted() @@ -311,7 +311,7 @@ class SignedAppStoreContractAcquirer( path = extracted.specFile, document = extracted.document, apiVersion = runCatching { - JSONObject(extracted.document).optJSONObject("info")?.optString("version") + JSONObject(extracted.document).optJSONObject("info")?.stringValue("version") }.getOrNull(), sourceUrl = "$sourceRoot/${extracted.specFile}", ) @@ -348,7 +348,7 @@ class SignedAppStoreContractAcquirer( if (depth > MAX_EXTERNAL_REFERENCE_DEPTH) return value return when (value) { is JSONObject -> { - val reference = value.optString("${'$'}ref").takeIf(String::isNotBlank) + val reference = value.stringValue("${'$'}ref")?.takeIf(String::isNotBlank) if (reference != null) { val (filePart, fragment) = splitSchemaReference(reference) ?: return value if (filePart.isEmpty() && preserveLocalReferences) return value @@ -587,14 +587,14 @@ internal fun extractOpenApiContract( private fun parseOpenApiCandidate(path: String, bytes: ByteArray): OpenApiCandidate? { val document = parseStructuredObject(path, bytes) ?: return null - if (!document.optString("openapi").startsWith("3.") || document.optJSONObject("paths") == null) { + if (document.stringValue("openapi")?.startsWith("3.") != true || document.optJSONObject("paths") == null) { return null } val normalized = document.toString() return OpenApiCandidate( path = path, document = normalized, - apiVersion = document.optJSONObject("info")?.optString("version")?.takeIf(String::isNotBlank), + apiVersion = document.optJSONObject("info")?.stringValue("version")?.takeIf(String::isNotBlank), ) } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt index b053644a9..3198b43f9 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt @@ -402,7 +402,7 @@ internal fun mergeOpenApiWithVerifiedReadRoutes( val pathItem = paths.optJSONObject(path) ?: return@mapNotNull null val operation = pathItem.optJSONObject("get") ?: return@mapNotNull null if (!operation.isCollectionRead(document)) return@mapNotNull null - val operationId = operation.optString("operationId").takeIf(String::isNotBlank) + val operationId = operation.stringValue("operationId")?.takeIf(String::isNotBlank) ?: return@mapNotNull null CollectionReadContract( operationId = operationId, @@ -419,7 +419,7 @@ internal fun mergeOpenApiWithVerifiedReadRoutes( val routePathItem = routePaths.optJSONObject(path) ?: return@forEach VERIFIED_STATIC_WRITE_METHODS.forEach writeMethod@ { method -> val routeWrite = routePathItem.optJSONObject(method) - ?.takeIf { operation -> operation.optBoolean(VERIFIED_CRUD_EXTENSION) } + ?.takeIf { operation -> operation.booleanValue(VERIFIED_CRUD_EXTENSION, absent = false, wrongType = false) } ?: return@writeMethod val targetPathItem = paths.optJSONObject(path) ?: JSONObject().also { paths.put(path, it) } if (targetPathItem.optJSONObject(method) == null) { @@ -432,7 +432,7 @@ internal fun mergeOpenApiWithVerifiedReadRoutes( } } val routeOperationalPost = routePathItem.optJSONObject("post") - ?.takeIf { operation -> operation.optString(OPERATIONAL_ACTION_EXTENSION) == "refresh" } + ?.takeIf { operation -> operation.stringValue(OPERATIONAL_ACTION_EXTENSION) == "refresh" } if (routeOperationalPost != null) { val targetPathItem = paths.optJSONObject(path) ?: JSONObject().also { paths.put(path, it) } if (targetPathItem.optJSONObject("post") == null) { @@ -457,13 +457,13 @@ internal fun mergeOpenApiWithVerifiedReadRoutes( preferred.requiredInputs.sameSemanticInputs(requiredInputs) }?.let { preferred -> importedOperation.put(FALLBACK_FOR_OPERATION_EXTENSION, preferred.operationId) - val fallbackOperationId = importedOperation.optString("operationId") + val fallbackOperationId = importedOperation.stringValue("operationId").orEmpty() if (fallbackOperationId.isNotBlank()) { val fallbacks = preferred.operation.optJSONArray(READ_FALLBACKS_EXTENSION) ?: JSONArray().also { preferred.operation.put(READ_FALLBACKS_EXTENSION, it) } if ((0 until fallbacks.length()).none { index -> - fallbacks.optString(index) == fallbackOperationId + fallbacks.opt(index) == fallbackOperationId } ) { fallbacks.put(fallbackOperationId) @@ -501,10 +501,10 @@ private fun requiredReadInputs(pathItem: JSONObject, operation: JSONObject): Set .forEach { parameters -> repeat(parameters.length()) { index -> val parameter = parameters.optJSONObject(index) ?: return@repeat - val location = parameter.optString("in") + val location = parameter.stringValue("in") if (location != "path" && location != "query") return@repeat - if (!parameter.optBoolean("required", location == "path")) return@repeat - val name = parameter.optString("name").normalizedSemanticWord() + if (!parameter.booleanValue("required", absent = location == "path", wrongType = true)) return@repeat + val name = parameter.stringValue("name").orEmpty().normalizedSemanticWord() if (name.isNotBlank()) add("$location:$name") } } @@ -547,13 +547,13 @@ private fun JSONObject.collectionShape( depth: Int, ): Boolean { if (depth > 8) return false - val reference = optString("${'$'}ref").takeIf { it.startsWith("#/") } + val reference = stringValue("${'$'}ref")?.takeIf { it.startsWith("#/") } if (reference != null && visitedReferences.add(reference)) { val resolved = document.resolveLocalObject(reference) if (resolved?.collectionShape(document, visitedReferences, depth + 1) == true) return true } - if (optString("type") == "array") return true - if (optString("type") == "object" && opt("additionalProperties") is JSONObject) return true + if (stringValue("type") == "array") return true + if (stringValue("type") == "object" && opt("additionalProperties") is JSONObject) return true val properties = optJSONObject("properties") listOf("ocs", "data").forEach { name -> if (properties?.optJSONObject(name)?.collectionShape(document, visitedReferences, depth + 1) == true) { diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValues.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValues.kt new file mode 100644 index 000000000..cc19b0148 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValues.kt @@ -0,0 +1,20 @@ +package dev.obiente.nextcloudnative.contracts + +import org.json.JSONObject + +/* + * Typed reads of untrusted catalog and contract JSON. org.json's optString and optBoolean coerce + * numbers, objects, arrays, and strings such as "true" into the requested type, so a value of the + * wrong type could pass as a well-formed one. These reads accept only the exact JSON type. + */ + +/** The string at [name], or null when it is absent, JSON null, or any other type. */ +internal fun JSONObject.stringValue(name: String): String? = opt(name) as? String + +/** The boolean at [name]: [absent] when missing, and [wrongType] for any other value, including JSON null. */ +internal fun JSONObject.booleanValue(name: String, absent: Boolean, wrongType: Boolean): Boolean = + when (val value = opt(name)) { + null -> absent + is Boolean -> value + else -> wrongType + } diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogFieldTypeTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogFieldTypeTest.kt new file mode 100644 index 000000000..4bc3d6263 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/CatalogFieldTypeTest.kt @@ -0,0 +1,131 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +class CatalogFieldTypeTest { + private val wrongTypes: List> = listOf( + "number" to 402, + "boolean" to true, + "object" to JSONObject().put("value", "4.0.2"), + "array" to JSONArray().put("4.0.2"), + "null" to JSONObject.NULL, + ) + + @Test + fun `a required release string of any other JSON type is malformed, never coerced`() { + val fields = mapOf( + "version" to MalformedCatalogField.Version, + "download" to MalformedCatalogField.Download, + "signature" to MalformedCatalogField.Signature, + "signatureDigest" to MalformedCatalogField.SignatureDigest, + ) + for ((field, expected) in fields) { + for ((type, value) in wrongTypes) { + val catalog = catalog(release("4.0.2").put(field, value)) + val version = if (field == "version") null else "4.0.2" + assertEquals(listOf(MalformedCatalogEntry(version, expected)), parse(catalog).malformed, "$field $type") + assertEquals(malformedSource, acquireFrom(catalog), "$field $type") + } + } + } + + @Test + fun `a certificate of any other JSON type is malformed, never coerced`() { + for ((type, value) in wrongTypes) { + val catalog = catalog(release("4.0.2")) + catalog.getJSONObject(0).put("certificate", value) + assertEquals( + listOf(MalformedCatalogEntry("4.0.2", MalformedCatalogField.Certificate)), + parse(catalog).malformed, + type, + ) + assertEquals(malformedSource, acquireFrom(catalog), type) + } + } + + @Test + fun `an isNightly flag that is not a boolean leaves the release channel unknown`() { + for (value in listOf("false", 0, JSONObject(), JSONObject.NULL)) { + val catalog = catalog(release("4.0.2").put("isNightly", value)) + assertEquals( + listOf(MalformedCatalogEntry("4.0.2", MalformedCatalogField.Nightly)), + parse(catalog).malformed, + value.toString(), + ) + assertEquals(malformedSource, acquireFrom(catalog), value.toString()) + } + // A real nightly is skipped, not malformed, and an absent flag means a regular release. + val nightly = catalog(release("4.0.2").put("isNightly", true)) + assertEquals(emptyList(), parse(nightly).malformed) + assertEquals(ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoMatchingRelease), acquireFrom(nightly)) + val regular = release("4.0.2").also { it.remove("isNightly") } + assertEquals(listOf("4.0.2"), parse(catalog(regular)).releases.map(AppStoreRelease::version)) + } + + @Test + fun `a wrong-typed optional value is dropped instead of coerced`() { + val release = parse(catalog(release("4.0.2").put("platformVersionSpec", 34))).releases.single() + assertEquals(null, release.platformVersionSpec) + } + + @Test + fun `a wrong-typed non-matching entry beside a valid matching release still acquires`() { + MockWebServer().use { server -> + server.start() + val catalog = catalog( + release("5.1.0").put("signature", 12345), + release("4.0.2").put("version", JSONObject().put("value", "4.0.2")), + release("4.0.2", server = server), + ) + server.enqueue(MockResponse(body = catalog.toString())) + server.enqueue(MockResponse(body = "package")) + val outcome = acquirer(server) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + }.acquireOutcome(request) + assertEquals("4.0.2", assertIs(outcome).contract.contractVersion) + assertEquals(2, server.requestCount) + } + } + + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + private val malformedSource = ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.MalformedSource) + + private fun parse(catalog: JSONArray) = + parseReleases(catalog.toString(), "cospend", "https://fixture.invalid/apps.json", false) + + private fun acquireFrom(catalog: JSONArray): ContractAcquisitionOutcome = MockWebServer().use { server -> + server.start() + // The installed version is absent from the platform catalog, so the full catalog is read too. + repeat(2) { server.enqueue(MockResponse(body = catalog.toString())) } + acquirer(server) { error("No package may be verified.") }.acquireOutcome(request) + } + + private fun catalog(vararg releases: JSONObject): JSONArray = JSONArray().put( + JSONObject().put("id", "cospend").put("certificate", "certificate").put("releases", JSONArray(releases.toList())), + ) + + private fun release(version: String, server: MockWebServer? = null): JSONObject = JSONObject() + .put("version", version) + .put("download", server?.url("downloads/cospend-$version.tar.gz")?.toString() + ?: "https://fixture.invalid/cospend-$version.tar.gz") + .put("signature", "c2lnbmF0dXJl") + .put("signatureDigest", "sha512") + .put("isNightly", false) + + private fun acquirer(server: MockWebServer, verify: (AppStoreRelease) -> VerifiedPackageContract) = + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + ) +} diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValuesTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValuesTest.kt new file mode 100644 index 000000000..48c7c5ad0 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/StrictJsonValuesTest.kt @@ -0,0 +1,36 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.json.JSONArray +import org.json.JSONObject + +class StrictJsonValuesTest { + private val document = JSONObject() + .put("string", "3.0.3") + .put("number", 3.1) + .put("boolean", true) + .put("textBoolean", "true") + .put("object", JSONObject().put("value", "x")) + .put("array", JSONArray().put("x")) + .put("null", JSONObject.NULL) + + @Test + fun `only a JSON string is read as a string`() { + assertEquals("3.0.3", document.stringValue("string")) + for (name in listOf("number", "boolean", "object", "array", "null", "absent")) { + assertEquals(null, document.stringValue(name), name) + } + } + + @Test + fun `only a JSON boolean is read as a boolean, and other values use the stated fallback`() { + assertEquals(true, document.booleanValue("boolean", absent = false, wrongType = false)) + assertEquals(false, document.booleanValue("absent", absent = false, wrongType = true)) + assertEquals(true, document.booleanValue("absent", absent = true, wrongType = false)) + for (name in listOf("textBoolean", "number", "string", "object", "array", "null")) { + assertEquals(false, document.booleanValue(name, absent = true, wrongType = false), name) + assertEquals(true, document.booleanValue(name, absent = false, wrongType = true), name) + } + } +} From 45c0b0867b74438ecfe6ce2a7a4988e63ca8601a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 10:52:05 +0200 Subject: [PATCH 35/49] fix(contracts): report unusable bundled trust material as a client fault The bundled root certificates and revocation list are loaded and validated before any package check. A corrupt, expired, unsupported, or inconsistent bundle now throws ContractTrustMaterialInvalidException, classified as RuntimeIncompatible like a missing resource, instead of being wrapped as a package verification failure. Failures checking the package's certificate, chain, revocation, and signature remain VerificationFailed. --- .../contracts/ContractAcquisitionFailure.kt | 9 ++ .../NextcloudAppPackageTrustVerifier.kt | 65 +++++++--- .../SignedAppStoreContractAcquirer.kt | 2 +- .../contracts/BundledTrustMaterialTest.kt | 114 ++++++++++++++++++ 4 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt index 5ca7d5677..3397d2e92 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionFailure.kt @@ -49,6 +49,14 @@ class ContractSourceTooLargeException(val limit: Long, message: String) : Illega */ class ContractTrustResourceMissingException(message: String) : RuntimeException(message) +/** + * The bundled trust material is present but unusable: corrupt, expired, of an unsupported format, + * or inconsistent. Like a missing resource, this is a local client fault that only updating or + * repairing the app can fix, so it is never reported as a source verification failure. + */ +class ContractTrustMaterialInvalidException(cause: Throwable) : + RuntimeException("The bundled Nextcloud trust material is unusable.", cause) + /** A package or source failed verification. The original failure stays available as the cause. */ class ContractSourceVerificationException(message: String, cause: Throwable? = null) : SecurityException(message, cause) @@ -72,6 +80,7 @@ fun classifyContractAcquisitionFailure(failure: Throwable): ContractAcquisitionF is GeneralSecurityException -> ContractAcquisitionFailureKind.VerificationFailed is LinkageError -> ContractAcquisitionFailureKind.RuntimeIncompatible is ContractTrustResourceMissingException -> ContractAcquisitionFailureKind.RuntimeIncompatible + is ContractTrustMaterialInvalidException -> ContractAcquisitionFailureKind.RuntimeIncompatible else -> ContractAcquisitionFailureKind.Unexpected } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt index 914e73604..2600a68b3 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt @@ -2,29 +2,31 @@ package dev.obiente.nextcloudnative.contracts import java.io.ByteArrayInputStream import java.io.InputStream +import java.security.GeneralSecurityException import java.security.Signature import java.security.cert.CertificateFactory import java.security.cert.X509CRL import java.security.cert.X509Certificate import java.util.Base64 +import java.util.Date -class NextcloudAppPackageTrustVerifier( - private val trustResourceLoader: (String) -> ByteArray = ::loadBundledTrustResource, +class NextcloudAppPackageTrustVerifier internal constructor( + private val trustResourceLoader: (String) -> ByteArray, + private val now: () -> Date, ) : AppPackageTrustVerifier { + constructor(trustResourceLoader: (String) -> ByteArray = ::loadBundledTrustResource) : + this(trustResourceLoader, ::Date) + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract { require(release.signatureDigest.equals("sha512", ignoreCase = true)) { "The package uses an unsupported signature digest." } - val factory = CertificateFactory.getInstance("X.509") - val trustedCertificates = factory.generateCertificates( - ByteArrayInputStream(trustResourceLoader(ROOT_CERTIFICATES_RESOURCE)), - ).map { certificate -> certificate as X509Certificate } - check(trustedCertificates.isNotEmpty()) { "The Nextcloud trust bundle is empty." } - trustedCertificates.forEach(X509Certificate::checkValidity) + // Local trust material is loaded and validated first; only failures after this point concern the package. + val (factory, trustedCertificates, crl) = loadTrustMaterial() val appCertificate = factory.generateCertificate( ByteArrayInputStream(release.certificatePem.encodeToByteArray()), ) as X509Certificate - appCertificate.checkValidity() + appCertificate.checkValidity(now()) check(appCertificate.subjectCommonName() == release.appId) { "The package certificate does not belong to ${release.appId}." } @@ -32,13 +34,6 @@ class NextcloudAppPackageTrustVerifier( "The package certificate uses an unsupported key algorithm." } verifyCertificateChain(appCertificate, trustedCertificates) - val crl = factory.generateCRL( - ByteArrayInputStream(trustResourceLoader(ROOT_REVOCATION_LIST_RESOURCE)), - ) as X509CRL - val crlIssuer = trustedCertificates.firstOrNull { certificate -> - certificate.subjectX500Principal == crl.issuerX500Principal - } ?: error("The CRL issuer is missing from the Nextcloud trust bundle.") - crl.verify(crlIssuer.publicKey) check(!crl.isRevoked(appCertificate)) { "The package certificate has been revoked." } val signature = Signature.getInstance("SHA512withRSA") signature.initVerify(appCertificate.publicKey) @@ -47,6 +42,44 @@ class NextcloudAppPackageTrustVerifier( check(signature.verify(signatureBytes)) { "The downloaded app package signature is invalid." } return extractOpenApiContract(archive, release) } + + /** + * Loads and validates the bundled root certificates and revocation list. Any failure here is a + * local client fault, thrown as [ContractTrustMaterialInvalidException] or + * [ContractTrustResourceMissingException]; it never describes the downloaded package. + */ + private fun loadTrustMaterial(): TrustMaterial { + val certificateBytes = trustResourceLoader(ROOT_CERTIFICATES_RESOURCE) + val revocationBytes = trustResourceLoader(ROOT_REVOCATION_LIST_RESOURCE) + return try { + val factory = CertificateFactory.getInstance("X.509") + val trusted = factory.generateCertificates(ByteArrayInputStream(certificateBytes)) + .map { certificate -> certificate as X509Certificate } + check(trusted.isNotEmpty()) { "The Nextcloud trust bundle is empty." } + val validAt = now() + trusted.forEach { certificate -> certificate.checkValidity(validAt) } + val crl = factory.generateCRL(ByteArrayInputStream(revocationBytes)) as X509CRL + val crlIssuer = trusted.firstOrNull { certificate -> + certificate.subjectX500Principal == crl.issuerX500Principal + } ?: error("The CRL issuer is missing from the Nextcloud trust bundle.") + crl.verify(crlIssuer.publicKey) + check(crl.nextUpdate?.after(validAt) != false) { "The bundled Nextcloud revocation list has expired." } + TrustMaterial(factory, trusted, crl) + } catch (failure: GeneralSecurityException) { + throw ContractTrustMaterialInvalidException(failure) + } catch (failure: IllegalStateException) { + throw ContractTrustMaterialInvalidException(failure) + } catch (failure: ClassCastException) { + // A resource of the wrong kind, for example a certificate where the revocation list belongs. + throw ContractTrustMaterialInvalidException(failure) + } + } + + private data class TrustMaterial( + val factory: CertificateFactory, + val trustedCertificates: List, + val revocationList: X509CRL, + ) } private fun verifyCertificateChain( diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 7aaa96e2c..35ff7972f 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -152,7 +152,7 @@ class SignedAppStoreContractAcquirer( } catch (failure: JSONException) { throw failure.asSourceVerificationFailure(request.appId) } - // A missing bundled trust resource or parser configuration fault is local and propagates. + // Missing or unusable bundled trust material, or a parser configuration fault, is local and propagates. if (verified.contractKind == VerifiedContractKind.VerifiedReadRoutes) { acquireGitHubTagContract( release = release, diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt new file mode 100644 index 000000000..7d636cf34 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt @@ -0,0 +1,114 @@ +package dev.obiente.nextcloudnative.contracts + +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +class BundledTrustMaterialTest { + private val roots = loadBundledTrustResource(ROOTS) + private val revocations = loadBundledTrustResource(REVOCATIONS) + + @Test + fun `a corrupt bundled root certificate is a client fault`() { + assertClientFault(verifier(roots = "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n".encodeToByteArray())) + assertClientFault(verifier(roots = ByteArray(0))) + } + + @Test + fun `an expired bundled revocation list is a client fault`() { + // The bundled certificates stay valid until 2041; the revocation list's next update is in 2036. + val validUntil2036 = verifier(now = Date(EARLY_2037_MILLIS)) + assertClientFault(validUntil2036) + } + + @Test + fun `bundled trust resources of an unsupported format are client faults`() { + assertClientFault(verifier(revocations = roots)) + assertClientFault(verifier(roots = revocations)) + } + + @Test + fun `a package certificate that fails identity checks is still a verification failure`() { + // A well-formed certificate from the bundled chain, presented for another app. + val failure = assertFailsWith { + acquireWith(verifier(), certificate = intermediatePem()) + } + assertEquals(ContractAcquisitionFailureKind.VerificationFailed, classifyContractAcquisitionFailure(failure)) + assertFalse(failure.cause is ContractTrustMaterialInvalidException) + } + + @Test + fun `a bad package signature over valid trust material is still a verification failure`() { + // The certificate chains to the bundled root and is not revoked, so only the signature fails. + val release = AppStoreRelease( + appId = INTERMEDIATE_COMMON_NAME, + version = "1.0.0", + downloadUrl = "https://fixture.invalid/package.tar.gz", + signature = "c2lnbmF0dXJl", + signatureDigest = "sha512", + certificatePem = intermediatePem(), + catalogUrl = "https://fixture.invalid/apps.json", + ) + val failure = assertFailsWith { verifier().verifyAndExtract(release, "package".encodeToByteArray()) } + assertFalse(failure is ContractTrustMaterialInvalidException, failure.toString()) + assertFalse(failure is ContractTrustResourceMissingException, failure.toString()) + assertEquals( + ContractAcquisitionFailureKind.VerificationFailed, + classifyContractAcquisitionFailure(failure.asSourceVerificationFailure("example")), + ) + } + + private fun assertClientFault(verifier: NextcloudAppPackageTrustVerifier) { + val failure = assertFailsWith { acquireWith(verifier) } + assertEquals(ContractAcquisitionFailureKind.RuntimeIncompatible, classifyContractAcquisitionFailure(failure)) + } + + private fun verifier(roots: ByteArray = this.roots, revocations: ByteArray = this.revocations, now: Date? = null) = + NextcloudAppPackageTrustVerifier( + trustResourceLoader = { name -> if (name == ROOTS) roots else revocations }, + now = { now ?: Date() }, + ) + + private fun intermediatePem(): String { + val text = roots.decodeToString() + return text.substring(0, text.indexOf("-----END CERTIFICATE-----") + "-----END CERTIFICATE-----".length) + "\n" + } + + private fun acquireWith( + verifier: AppPackageTrustVerifier, + certificate: String = "certificate", + ): ContractAcquisitionOutcome = MockWebServer().use { server -> + server.start() + val release = JSONObject() + .put("version", "4.0.2") + .put("download", server.url("downloads/cospend-4.0.2.tar.gz").toString()) + .put("signature", "c2lnbmF0dXJl") + .put("signatureDigest", "sha512") + .put("isNightly", false) + val catalog = JSONArray().put( + JSONObject().put("id", "cospend").put("certificate", certificate).put("releases", JSONArray().put(release)), + ) + server.enqueue(MockResponse(body = catalog.toString())) + server.enqueue(MockResponse(body = "package")) + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = verifier, + ).acquireOutcome(ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2")) + } + + private companion object { + const val ROOTS = "nextcloud-codesigning/root.crt" + const val REVOCATIONS = "nextcloud-codesigning/root.crl" + const val INTERMEDIATE_COMMON_NAME = "Nextcloud Code Signing Intermediate Authority" + const val EARLY_2037_MILLIS = 2_114_380_800_000L + } +} From 8e8c22d636f1531952b6e1fb15bd8e3e7c814d1b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 10:52:13 +0200 Subject: [PATCH 36/49] fix(contracts): keep the exact-release catalog reason after compatible candidates fail When the exact installed release could not be inspected because its catalog was unreadable or its entry malformed, and no patch-compatible candidate provides a contract, acquisition now reports UnreadableCatalog or MalformedSource instead of NoUsableContract. A temporarily unavailable linked source still takes precedence, because a contract may exist there. --- .../contracts/AppStoreCatalogReleases.kt | 19 +++++ .../contracts/ContractAcquisitionOutcome.kt | 16 ++-- .../SignedAppStoreContractAcquirer.kt | 5 +- .../contracts/ExactReleasePrecedenceTest.kt | 73 +++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ExactReleasePrecedenceTest.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index 93446886e..f6ad21ff5 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -70,6 +70,25 @@ internal fun unmatchedReleaseOutcome( ) } +/** + * Why the exact installed release could not be inspected: its catalog was unreadable, or an entry + * that may be the exact release was malformed. Null when the catalogs were readable and simply do + * not list it. + */ +internal fun uninspectedExactReleaseReason( + installedVersion: String?, + platformCatalog: CatalogReleases, + allCatalog: CatalogReleases?, +): ContractUnavailableReason? { + if (installedVersion == null) return null + if (!platformCatalog.documentReadable || allCatalog?.documentReadable == false) { + return ContractUnavailableReason.UnreadableCatalog + } + val malformedExact = (platformCatalog.malformed + allCatalog?.malformed.orEmpty()) + .any { entry -> entry.version == null || entry.version == installedVersion } + return if (malformedExact) ContractUnavailableReason.MalformedSource else null +} + internal fun selectRelease( catalogJson: String, request: ContractAcquisitionRequest, diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt index 9485ed862..a138714a3 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -54,7 +54,13 @@ internal fun refreshShortcut( } /** Evidence gathered by one uncoalesced acquisition that decides its no-contract reason. */ -internal class ContractAcquisitionAttempt { +/** + * Records what inspecting candidate releases found. When no candidate provides a contract, a + * temporarily unavailable linked source wins, because a contract may still exist; then an exact + * installed release that could not be inspected ([exactReleaseUninspected]); and only then + * [ContractUnavailableReason.NoUsableContract], which claims the inspected releases provide no API. + */ +internal class ContractAcquisitionAttempt(private val exactReleaseUninspected: ContractUnavailableReason? = null) { var sourceTemporarilyUnavailable: Boolean = false private set @@ -63,10 +69,10 @@ internal class ContractAcquisitionAttempt { } fun unavailable(): ContractAcquisitionOutcome.Unavailable = ContractAcquisitionOutcome.Unavailable( - if (sourceTemporarilyUnavailable) { - ContractUnavailableReason.SourceTemporarilyUnavailable - } else { - ContractUnavailableReason.NoUsableContract + when { + sourceTemporarilyUnavailable -> ContractUnavailableReason.SourceTemporarilyUnavailable + exactReleaseUninspected != null -> exactReleaseUninspected + else -> ContractUnavailableReason.NoUsableContract }, ) } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 35ff7972f..ee19026c8 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -114,7 +114,10 @@ class SignedAppStoreContractAcquirer( ).filterNot { candidate -> candidate.version == exactRelease?.version } val releases = listOfNotNull(exactRelease) + compatibleReleases if (releases.isEmpty()) return unmatchedReleaseOutcome(request.installedAppVersion, platformCatalog, allCatalog) - val attempt = ContractAcquisitionAttempt() + val attempt = ContractAcquisitionAttempt( + exactReleaseUninspected = uninspectedExactReleaseReason(request.installedAppVersion, platformCatalog, allCatalog) + .takeIf { exactRelease == null }, + ) val missingPackageContracts = mutableListOf() val verifiedRouteFallbacks = mutableListOf>() diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ExactReleasePrecedenceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ExactReleasePrecedenceTest.kt new file mode 100644 index 000000000..26a78311c --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ExactReleasePrecedenceTest.kt @@ -0,0 +1,73 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +/** + * The installed version 4.0.2 is absent from the platform catalog, which offers the compatible 4.0.1, + * so the full catalog is read for the exact release. + */ +class ExactReleasePrecedenceTest { + @Test + fun `an unreadable exact catalog outranks a compatible candidate without a contract`() { + assertEquals(unavailable(ContractUnavailableReason.UnreadableCatalog), acquire(fullCatalog = "[truncated")) + } + + @Test + fun `a malformed exact entry outranks a compatible candidate without a contract`() { + val full = catalog(release("4.0.2").also { it.remove("signature") }, release("4.0.1")).toString() + assertEquals(unavailable(ContractUnavailableReason.MalformedSource), acquire(fullCatalog = full)) + } + + @Test + fun `a readable exact catalog without the version reports that candidates provide no contract`() { + val full = catalog(release("4.0.1")).toString() + assertEquals(unavailable(ContractUnavailableReason.NoUsableContract), acquire(fullCatalog = full)) + } + + @Test + fun `a compatible candidate with a contract is still acquired beside an unreadable exact catalog`() { + val outcome = acquire(fullCatalog = "[truncated") { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + } + assertEquals("4.0.1", assertIs(outcome).contract.contractVersion) + } + + private fun acquire( + fullCatalog: String, + verify: (AppStoreRelease) -> VerifiedPackageContract = { throw OpenApiContractMissingException("synthetic") }, + ): ContractAcquisitionOutcome = MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse(body = catalog(release("4.0.1", server)).toString())) + server.enqueue(MockResponse(body = fullCatalog)) + server.enqueue(MockResponse(body = "package")) + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + ).acquireOutcome(ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2")) + } + + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun catalog(vararg releases: JSONObject): JSONArray = JSONArray().put( + JSONObject().put("id", "cospend").put("certificate", "certificate").put("releases", JSONArray(releases.toList())), + ) + + private fun release(version: String, server: MockWebServer? = null): JSONObject = JSONObject() + .put("version", version) + .put("download", server?.url("downloads/cospend-$version.tar.gz")?.toString() + ?: "https://fixture.invalid/cospend-$version.tar.gz") + .put("signature", "c2lnbmF0dXJl") + .put("signatureDigest", "sha512") + .put("isNightly", false) +} From 8ef49e0993502e16972e3f9ddf0900a074d44008 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 10:52:23 +0200 Subject: [PATCH 37/49] fix(contracts): stop stale acquisitions from overwriting newer verified contracts Each acquisition takes a per-request generation when it starts. A verified contract is published only when no later acquisition has published and no invalidation has happened since it started, so a slower automatic acquisition cannot overwrite an explicit refresh's result or bring back an invalidated contract; a superseded caller receives the newer contract. The bookkeeping is in memory per acquirer and never waits, so a cancelled acquisition cannot hold back later ones. --- .../contracts/ContractPublicationFence.kt | 49 +++++ .../SignedAppStoreContractAcquirer.kt | 40 ++-- .../contracts/ContractPublicationFenceTest.kt | 175 ++++++++++++++++++ 3 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt new file mode 100644 index 000000000..3e17f3f35 --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt @@ -0,0 +1,49 @@ +package dev.obiente.nextcloudnative.contracts + +/** + * Orders publication of verified contracts per request, that is per app, server version, and + * installed version. + * + * Each acquisition takes a generation when it starts. Its result is published only when its + * generation is newer than the last publication or invalidation for the same request, so a slower + * acquisition that started before a refresh cannot overwrite the refresh's result, and one that + * started before an invalidation cannot bring the invalidated contract back. Bookkeeping is in + * memory, per acquirer, and never waits for an acquisition, so a cancelled or failed acquisition + * cannot hold back later ones. + */ +internal class ContractPublicationFence { + private class Generations { + /** The newest generation handed to an acquisition. */ + var started = 0L + + /** Results from this generation or older are no longer published. */ + var fenced = 0L + } + + private val generations = HashMap() + + /** Starts an acquisition for [request] and returns its generation. */ + fun start(request: ContractAcquisitionRequest): Long = synchronized(generations) { + generations.getOrPut(request, ::Generations).let { entry -> ++entry.started } + } + + /** + * Runs [publish] and returns true when [generation] is newer than the last publication or + * invalidation for [request]. Returns false, without publishing, for a superseded result. + */ + fun publishIfCurrent(request: ContractAcquisitionRequest, generation: Long, publish: () -> Unit): Boolean = + synchronized(generations) { + val entry = generations.getOrPut(request, ::Generations) + if (generation <= entry.fenced) return false + entry.fenced = generation + publish() + true + } + + /** Runs [discard] and fences out every acquisition for [request] that has already started. */ + fun invalidate(request: ContractAcquisitionRequest, discard: () -> T): T = synchronized(generations) { + val entry = generations.getOrPut(request, ::Generations) + entry.fenced = maxOf(entry.fenced, entry.started) + discard() + } +} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index ee19026c8..b7067bfe2 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -36,6 +36,7 @@ class SignedAppStoreContractAcquirer( private val verifiedContracts = ConcurrentHashMap() private val inFlightAcquisitions = ConcurrentHashMap, CompletableFuture>() + private val publicationFence = ContractPublicationFence() init { if (requireHttps) { @@ -73,7 +74,8 @@ class SignedAppStoreContractAcquirer( } } return try { - acquireUncoalesced(request, cachePolicy).also { acquired -> owner.complete(acquired) } + val generation = publicationFence.start(request) + acquireUncoalesced(request, cachePolicy, generation).also { acquired -> owner.complete(acquired) } } catch (failure: Throwable) { owner.completeExceptionally(failure) throw failure @@ -85,6 +87,7 @@ class SignedAppStoreContractAcquirer( private fun acquireUncoalesced( request: ContractAcquisitionRequest, cachePolicy: AcquisitionCachePolicy, + generation: Long, ): ContractAcquisitionOutcome { requireValidRequest(request.appId.matches(APP_ID_PATTERN)) { "The app ID is invalid." } val coreServerVersion = normalizeServerVersion(request.serverVersion) @@ -165,30 +168,35 @@ class SignedAppStoreContractAcquirer( )?.let { linkedContract -> return cacheContract( request, + generation, linkedContract.mergeVerifiedPackageRoutes(verified), ) } verifiedRouteFallbacks += release to verified continue } - return cacheContract(request, verified.asPackageContract(request, release)) + return cacheContract(request, generation, verified.asPackageContract(request, release)) } for (release in missingPackageContracts) { val exact = release.version == request.installedAppVersion acquireGitHubTagContract(release, request.installedAppVersion, exact, attempt)?.let { contract -> - return cacheContract(request, contract) + return cacheContract(request, generation, contract) } } val (release, verified) = verifiedRouteFallbacks.firstOrNull() ?: return attempt.unavailable() - return cacheContract(request, verified.asPackageContract(request, release)) + return cacheContract(request, generation, verified.asPackageContract(request, release)) } - /** Forgets the verified contract for [request], for example after it proved unusable downstream. */ - fun invalidateVerifiedContract(request: ContractAcquisitionRequest): VerifiedContractRemoval { - verifiedContracts.remove(request) - return verifiedContractCache.remove(request) - } + /** + * Forgets the verified contract for [request], for example after it proved unusable downstream. + * Acquisitions already running for [request] can no longer publish their result. + */ + fun invalidateVerifiedContract(request: ContractAcquisitionRequest): VerifiedContractRemoval = + publicationFence.invalidate(request) { + verifiedContracts.remove(request) + verifiedContractCache.remove(request) + } private fun VerifiedPackageContract.asPackageContract( request: ContractAcquisitionRequest, @@ -210,12 +218,20 @@ class SignedAppStoreContractAcquirer( contractKind = contractKind, ) + /** + * Publishes [contract] unless an acquisition that started later, or an invalidation, already + * superseded [generation]. A superseded caller receives the newer contract when one is published. + */ private fun cacheContract( request: ContractAcquisitionRequest, + generation: Long, contract: VerifiedOpenApiContract, - ): ContractAcquisitionOutcome = ContractAcquisitionOutcome.Acquired(contract).also { - verifiedContracts[request] = contract - runCatching { verifiedContractCache.store(request, contract) } + ): ContractAcquisitionOutcome { + val published = publicationFence.publishIfCurrent(request, generation) { + verifiedContracts[request] = contract + runCatching { verifiedContractCache.store(request, contract) } + } + return ContractAcquisitionOutcome.Acquired(if (published) contract else verifiedContracts[request] ?: contract) } private fun acquireGitHubTagContract( diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt new file mode 100644 index 000000000..b41e12321 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt @@ -0,0 +1,175 @@ +package dev.obiente.nextcloudnative.contracts + +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +class ContractPublicationFenceTest { + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val cache = MemoryVerifiedContractCache() + private val verifications = AtomicInteger() + private val firstEntered = CountDownLatch(1) + private val releaseFirst = CountDownLatch(1) + private var firstFails = false + private var now = 50_000_000L + + @AfterTest + fun shutDown() { + releaseFirst.countDown() + executor.shutdownNow() + } + + @Test + fun `generations publish in start order and invalidation fences running acquisitions`() { + val fence = ContractPublicationFence() + val older = fence.start(request) + val newer = fence.start(request) + assertTrue(fence.publishIfCurrent(request, newer) {}) + assertFalse(fence.publishIfCurrent(request, older) { error("A superseded result must not publish.") }) + + val running = fence.start(request) + fence.invalidate(request) {} + assertFalse(fence.publishIfCurrent(request, running) { error("An invalidated result must not publish.") }) + assertTrue(fence.publishIfCurrent(request, fence.start(request)) {}) + // Requests are fenced independently. + assertTrue(fence.publishIfCurrent(request.copy(installedAppVersion = "4.0.3"), 1) {}) + } + + @Test + fun `a slower acquisition that started before a refresh cannot overwrite the refresh result`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val stale = startBlockedAcquisition(server, acquirer) + + enqueueCatalogAndPackage(server) + assertEquals(document(2), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + releaseFirst.countDown() + + // The superseded caller receives the newer contract, and the cache keeps it. + assertEquals(document(2), document(stale.get(10, TimeUnit.SECONDS))) + assertEquals(document(2), cache.load(request)?.document) + val requests = server.requestCount + assertEquals(document(2), document(acquirer.acquireOutcome(request))) + assertEquals(requests, server.requestCount) + } + } + + @Test + fun `the newer of two refreshes wins over each older acquisition`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val stale = startBlockedAcquisition(server, acquirer) + + enqueueCatalogAndPackage(server) + assertEquals(document(2), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + now += 61_000L + enqueueCatalogAndPackage(server) + assertEquals(document(3), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + releaseFirst.countDown() + + assertEquals(document(3), document(stale.get(10, TimeUnit.SECONDS))) + assertEquals(document(3), cache.load(request)?.document) + } + } + + @Test + fun `a cancelled older acquisition never holds back later publication`() { + MockWebServer().use { server -> + server.start() + firstFails = true + val acquirer = acquirer(server) + val cancelled = startBlockedAcquisition(server, acquirer) + + // A refresh publishes while the older acquisition is still running. + enqueueCatalogAndPackage(server) + assertEquals(document(2), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + releaseFirst.countDown() + assertIs(assertFailsWith { cancelled.get(10, TimeUnit.SECONDS) }.cause) + + now += 61_000L + enqueueCatalogAndPackage(server) + assertEquals(document(3), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + assertEquals(document(3), cache.load(request)?.document) + } + } + + @Test + fun `an acquisition running during invalidation cannot bring the contract back`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val running = startBlockedAcquisition(server, acquirer) + + assertEquals(VerifiedContractRemoval.Removed, acquirer.invalidateVerifiedContract(request)) + releaseFirst.countDown() + running.get(10, TimeUnit.SECONDS) + assertNull(cache.load(request)) + + // The next automatic discovery acquires again; the catalog is still cached. + server.enqueue(MockResponse(body = "package")) + assertEquals(document(2), document(acquirer.acquireOutcome(request))) + assertEquals(document(2), cache.load(request)?.document) + } + } + + /** Starts an automatic acquisition and waits until it is blocked verifying its package. */ + private fun startBlockedAcquisition( + server: MockWebServer, + acquirer: SignedAppStoreContractAcquirer, + ): Future { + enqueueCatalogAndPackage(server) + val acquisition = executor.submit { acquirer.acquireOutcome(request) } + assertTrue(firstEntered.await(10, TimeUnit.SECONDS)) + return acquisition + } + + private fun document(version: Int) = "{\"version\":$version}" + + private fun document(outcome: ContractAcquisitionOutcome): String = + assertIs(outcome).contract.document + + private fun enqueueCatalogAndPackage(server: MockWebServer) { + val download = server.url("downloads/cospend-4.0.2.tar.gz") + server.enqueue(MockResponse(body = """[{"id":"cospend","certificate":"c","releases":[""" + + """{"version":"4.0.2","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""")) + server.enqueue(MockResponse(body = "package")) + } + + private fun acquirer(server: MockWebServer) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray): VerifiedPackageContract { + val call = verifications.incrementAndGet() + if (call == 1) { + firstEntered.countDown() + check(releaseFirst.await(10, TimeUnit.SECONDS)) { "The blocked verification was never released." } + if (firstFails) throw CancellationException("synthetic cancellation") + } + return VerifiedPackageContract(release.appId, release.version, "openapi.json", document(call)) + } + }, + verifiedContractCache = cache, + catalogRefreshThrottle = CatalogRefreshThrottle(nowMillis = { now }), + ) +} From 5f4436d74952fb407405f2f80c3fc1ab042db292 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 10:52:24 +0200 Subject: [PATCH 38/49] fix(ui): offer no retry when the server rejected the account's credentials Checking again after a 401 would resend the rejected credentials, and the app has no in-session reauthentication. The fallback card for that state now has no retry action and names where to sign in again: Settings, Account, then Sign out and revoke access. The permission-denied card keeps Check again and says it helps only after access changes or a release is added. --- .../app/DynamicContractFallback.kt | 16 ++++++++++++---- .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +++--- .../app/DynamicContractFallbackDiscoveryTest.kt | 13 +++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt index 2c3951410..eb41617e8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt @@ -239,7 +239,11 @@ internal const val DYNAMIC_DISCOVERY_FAILURE_MESSAGE = "Could not check this app internal data class DynamicContractFallbackNotice( val title: String, val message: String, - val actionLabel: String, + /** + * The label of the action that checks again, or null when checking again would repeat a request + * that cannot succeed, such as one made with credentials the server rejected. + */ + val actionLabel: String?, ) /** Product copy for a metadata fallback. It explains what happened and whether checking again can help. */ @@ -279,16 +283,20 @@ internal fun DynamicContractFallbackReason?.fallbackNotice(): DynamicContractFal "Store offered no verified contract. Check the connection and try again.", "Try again", ) + // Checking again would resend the rejected credentials, so no retry is offered. The app has no + // in-session reauthentication; signing out from account settings is the way to sign in again. DynamicContractFallbackReason.ServerAuthenticationRequired -> DynamicContractFallbackNotice( "Sign in again", "The server did not accept this account while checking for this app's API, and the App Store " + - "offered no verified contract. Sign in again to continue.", - "Try again", + "offered no verified contract. To sign in again, open Settings, then Account, choose Sign out " + + "and revoke access, and sign in to this server.", + null, ) DynamicContractFallbackReason.ServerPermissionDenied -> DynamicContractFallbackNotice( "Server API not permitted", "Your account does not have permission to read this server's API catalog, which may be limited " + - "to administrators, and the App Store offered no verified contract. Only app details are shown.", + "to administrators, and the App Store offered no verified contract. Only app details are shown. " + + "Checking again helps only after an administrator grants access or the App Store adds a release.", "Check again", ) DynamicContractFallbackReason.UnreadableCatalog -> DynamicContractFallbackNotice( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 6c9e0c352..7d429b61d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -4656,9 +4656,9 @@ private fun DynamicDiscoveredAppScreen( val notice = discovery.fallbackReason.fallbackNotice() Text(notice.title, style = MaterialTheme.typography.titleSmall) Text(notice.message, style = MaterialTheme.typography.bodySmall) - TextButton(onClick = onRetryDiscovery) { - Text(notice.actionLabel) - } + notice.actionLabel?.let { label -> TextButton(onClick = onRetryDiscovery) { + Text(label) + } } } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt index 721616398..6e7081860 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallbackDiscoveryTest.kt @@ -118,6 +118,19 @@ class DynamicContractFallbackDiscoveryTest { assertEquals("Try again", DynamicContractFallbackReason.AppStoreUnreachable.fallbackNotice().actionLabel) } + @Test + fun rejectedCredentialsOfferNoRetryAndSayWhereToSignInAgain() { + val rejected = DynamicContractFallbackReason.ServerAuthenticationRequired.fallbackNotice() + assertNull(rejected.actionLabel) + assertTrue("Settings" in rejected.message && "sign in" in rejected.message, rejected.message) + // Every other state still offers a way to check again. + val others = (DynamicContractFallbackReason.entries - DynamicContractFallbackReason.ServerAuthenticationRequired) + assertEquals(emptyList(), others.filter { it.fallbackNotice().actionLabel == null }) + val denied = DynamicContractFallbackReason.ServerPermissionDenied.fallbackNotice() + assertEquals("Check again", denied.actionLabel) + assertTrue("administrator grants access" in denied.message, denied.message) + } + private fun assertNoPrivateText(discovery: DynamicDescriptorDiscovery) { val text = discovery.diagnostics.joinToString("\n") for (secret in listOf("SyntheticKt", "private.invalid", "token", "NoClassDefFoundError", "IllegalState")) { From 2301b0a8d87e82f3d72141ba2f735d46fcbdb855 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 10:52:24 +0200 Subject: [PATCH 39/49] docs: describe trust faults, publication order, exact-release precedence, and rejected credentials --- DYNAMIC_APP_DESCRIPTOR.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 83085d6d8..8103291f3 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -253,6 +253,12 @@ unreadable catalog, a reason of its own, because no entry could be read; it is never cached, so checking again downloads it again. A corrupt or mismatched App Store-linked source archive yields no candidate, like an archive without a contract, instead of an unexpected client fault. +When the exact installed release cannot be inspected and the patch-compatible +candidates provide no contract, the result keeps the exact release's reason: +an unreadable catalog or a malformed exact entry outranks `NoUsableContract`, +which would claim that the matching releases provide no API. A linked source +that refused or rate limited a request still outranks both, because a contract +may exist there. App Store catalogs are cached for six hours. The first discovery of an app screen uses the cache. A discovery started by, or after, an explicit retry on @@ -279,15 +285,29 @@ life of the process, makes every later read a miss, and each later read or store retries the deletion. A `VERIFIED_CONTRACT_DELETION_FAILED` warning diagnostic records which of the two applied, with no path or exception text. +Publication of verified contracts is ordered per app, server version, and +installed version. Each acquisition takes a generation when it starts, and its +result is published only when no acquisition that started later has already +published and no invalidation has happened since it started. A slower +automatic acquisition that began before an explicit retry therefore cannot +overwrite the retry's result; its caller receives the newer contract instead. +The ordering is kept in memory for each acquirer and never waits for another +acquisition, so a cancelled or failed acquisition cannot hold back later ones. + [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. When probes disagree, the highest-precedence result wins, and the first one wins among equals: 1. HTTP 401 (`AuthenticationRequired`): the card asks the person to sign in - again. + again and offers no retry, because checking again would resend the rejected + credentials. The app has no reauthentication within a session, so the card + names the way to sign in again: Settings, Account, then Sign out and revoke + access. 2. HTTP 403 (`PermissionDenied`): the account may not read the catalog, which - may be limited to administrators. + may be limited to administrators. Its Check again action stays, and the card + says that checking again helps only after an administrator grants access or + the App Store adds a release. 3. Inconclusive results that a retry may resolve: no response, a 5xx status, HTTP 429, or a successful catalog or contract response that is not readable JSON or declares no OpenAPI version (`UnreadableResponse`). An OCS API @@ -325,10 +345,12 @@ only genuine client faults remain unexpected. Size limits throw | Catalog and package bodies | Declared or streamed size over the limit | Source unusable | | Catalog document | Truncated, empty, not JSON, or not an array | Unreadable catalog, never cached | | Catalog entry | Missing value, a required value of the wrong JSON type, or a download that is not an HTTP URL | Malformed source when it would be selected | +| Exact release after compatible candidates | Exact catalog unreadable or exact entry malformed, and no compatible candidate has a contract | Unreadable catalog or malformed source, not no usable contract | +| Verified contract publication | The acquisition was superseded by a later one or by an invalidation | Not published; the newer state stays | | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | -| Package verification | Signature, certificate, revocation, identity, archive structure, or app metadata rejected | Verification failed | -| Package verification | Bundled trust resource missing | Client fault (runtime incompatible); retrying cannot repair it | +| Package verification | The package's signature, certificate, chain, revocation, identity, archive structure, or app metadata rejected | Verification failed | +| Package verification | Bundled root certificate or revocation list missing, corrupt, expired, of an unsupported format, or inconsistent | Client fault (runtime incompatible); retrying cannot repair it | | Package archive limits | Entry, expanded, path, or metadata-count limit exceeded | Source unusable | | Package OpenAPI JSON or YAML | Unreadable or not OpenAPI 3 | Candidate skipped | | Linked metadata, tree, spec, and schema requests | 404; size over the limit; other status; no response | Skipped; skipped; source unavailable; network | From 2e622feaadc8b154b966b128d17a7ca705c03932 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 11:50:29 +0200 Subject: [PATCH 40/49] test(ui): expect the sign-in guidance without a retry for rejected credentials --- .../nextcloudnative/app/DynamicServerProbeFallbackTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt index f2c009704..f49ae7151 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicServerProbeFallbackTest.kt @@ -155,7 +155,8 @@ class DynamicServerProbeFallbackTest { @Test fun accessAndUnsupportedCopyGivesTheRightNextStepWithoutRequestDetails() { val authentication = DynamicContractFallbackReason.ServerAuthenticationRequired.fallbackNotice() - assertTrue("Sign in again to continue." in authentication.message) + assertTrue("To sign in again, open Settings" in authentication.message, authentication.message) + assertEquals(null, authentication.actionLabel) val permission = DynamicContractFallbackReason.ServerPermissionDenied.fallbackNotice() assertTrue("permission" in permission.message && "administrators" in permission.message) val unsupported = DynamicContractFallbackReason.UnsupportedContract.fallbackNotice() From 998076a8ccc729d3b01800071ce4f8d9437f0ae6 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:52:56 +0000 Subject: [PATCH 41/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index ec3ec01b3..249370553 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -585,7 +585,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "5c9bf75cfa09c5e53674974e08c207cdd34268c311b57a2b1b5a289f857153c2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractFallback.kt": "dc2e70639a8ca8dbcdbcb23f0cbfcffc3fbeab28d330c88b8962893e9a87b62e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "c24734ae869fb6fa144c257961c04285e24de9209761ea3cacc99a9208ca118a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryDiagnostics.kt": "753c8282cf2cd44408290198c6661c9b23883d83404b4e0ebefadf74b4bec7fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicDiscoveryPersistence.kt": "d79ec4f9fbd8fac36e66af7fc692941f81f2754bc873de417c0ee60b4c210403", @@ -752,7 +752,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "ff7b04865462f28448deff0230f6c817019e95b9f8b95c9cae81d324694c2afc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "c7e2f2d01fcf2291c01c48109c28b45860b7e5966c14c2de88370b6cd18e10c1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "e195b74b027dc506892e05b0746471c94c6bf204b046bc79a1c3cb1aca900509", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "1a22ff389eceb699fa31c400a4b47572adaf6742f7a5b95b3e24772059a9b2af", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", From 1e79e6ce38d5e7520fcca907b3fda2227aa76a68 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 13:27:47 +0200 Subject: [PATCH 42/49] fix(contracts): let definitive no-contract results supersede older acquisitions The publication fence now records every accepted ending per request. A verified contract or a definitive no-contract result (no matching release, no usable contract, malformed entry) supersedes every older generation; transient endings, cancellation, and served cache hits fence nothing, because they prove nothing about the contract. A superseded acquisition no longer reports its own rejected contract: it reports the newest accepted result, or no usable contract after an invalidation. The state table is documented on the fence and in the descriptor guide. --- DYNAMIC_APP_DESCRIPTOR.md | 34 +++-- .../contracts/ContractPublicationFence.kt | 121 ++++++++++++---- .../SignedAppStoreContractAcquirer.kt | 19 +-- .../contracts/ContractPublicationFenceTest.kt | 129 ++++++++++++++++-- 4 files changed, 247 insertions(+), 56 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 8103291f3..d4d320139 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -285,14 +285,30 @@ life of the process, makes every later read a miss, and each later read or store retries the deletion. A `VERIFIED_CONTRACT_DELETION_FAILED` warning diagnostic records which of the two applied, with no path or exception text. -Publication of verified contracts is ordered per app, server version, and -installed version. Each acquisition takes a generation when it starts, and its -result is published only when no acquisition that started later has already -published and no invalidation has happened since it started. A slower -automatic acquisition that began before an explicit retry therefore cannot -overwrite the retry's result; its caller receives the newer contract instead. -The ordering is kept in memory for each acquirer and never waits for another -acquisition, so a cancelled or failed acquisition cannot hold back later ones. +Results are ordered per app, server version, and installed version by +[`ContractPublicationFence.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt). +Each acquisition takes a generation when it starts. A generation that has not +been superseded ends as follows: + +| Ending | Effect on older generations | +| --- | --- | +| Verified contract | Published; every older generation is superseded | +| No matching release, no usable contract, or malformed entry | Recorded; every older generation is superseded | +| Refused linked source, unreadable catalog, throttled retry, source or network fault | None | +| Cancellation | None | +| Cached contract served without inspecting releases | None | + +An invalidation supersedes every generation already started. A transient +ending fences nothing because it proves nothing about the contract, so an +older acquisition that verifies a contract afterwards may still publish it. A +superseded acquisition never publishes or reports its own result; it reports +the newest accepted one: the newer contract, the newer no-contract reason, or, +after an invalidation, no usable contract. A slower automatic acquisition that +began before an explicit retry therefore cannot overwrite or contradict the +retry's result. A no-contract result does not remove a previously cached +contract. The ordering is kept in memory for each acquirer and never waits for +another acquisition, so a cancelled or failed acquisition cannot hold back +later ones. [`DynamicSameOriginDiscovery.kt`](ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt) records same-origin probe results as typed `DynamicServerProbeFailure` values. @@ -346,7 +362,7 @@ only genuine client faults remain unexpected. Size limits throw | Catalog document | Truncated, empty, not JSON, or not an array | Unreadable catalog, never cached | | Catalog entry | Missing value, a required value of the wrong JSON type, or a download that is not an HTTP URL | Malformed source when it would be selected | | Exact release after compatible candidates | Exact catalog unreadable or exact entry malformed, and no compatible candidate has a contract | Unreadable catalog or malformed source, not no usable contract | -| Verified contract publication | The acquisition was superseded by a later one or by an invalidation | Not published; the newer state stays | +| Verified contract publication | A newer generation published a contract or ended without one definitively, or an invalidation happened | Not published; the newest accepted result is reported | | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | | Package verification | The package's signature, certificate, chain, revocation, identity, archive structure, or app metadata rejected | Verification failed | diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt index 3e17f3f35..98962feb9 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt @@ -1,49 +1,110 @@ package dev.obiente.nextcloudnative.contracts /** - * Orders publication of verified contracts per request, that is per app, server version, and - * installed version. + * Orders the results of acquisitions per request, that is per app, server version, and installed + * version, so an older acquisition can never undo what a newer one established. * - * Each acquisition takes a generation when it starts. Its result is published only when its - * generation is newer than the last publication or invalidation for the same request, so a slower - * acquisition that started before a refresh cannot overwrite the refresh's result, and one that - * started before an invalidation cannot bring the invalidated contract back. Bookkeeping is in - * memory, per acquirer, and never waits for an acquisition, so a cancelled or failed acquisition - * cannot hold back later ones. + * Each key keeps `started` (the newest generation handed out), `fenced` (results from this + * generation or older are superseded), and `latest` (the newest accepted result). Both counters + * only grow. An acquisition takes generation `g = ++started` when it starts and ends in one row: + * + * | Event for generation g | g > fenced | g <= fenced (superseded) | + * |------------------------------------------------|---------------------------------------------|------------------------------| + * | Verified a contract | publish; fenced = g; latest = the contract | not published; return latest | + * | Definitive Unavailable | fenced = g; latest = that outcome | return latest | + * | Transient Unavailable, source or network fault | no change; return or throw its own result | same | + * | Cancelled | no change; the cancellation propagates | same | + * | Served a cached contract without inspecting | no change | same | + * | Invalidation (not a generation) | fenced = started; latest = invalidated | | + * + * Definitive reasons are evidence about the release: [ContractUnavailableReason.NoMatchingRelease], + * [ContractUnavailableReason.NoUsableContract], and [ContractUnavailableReason.MalformedSource]. + * A transient result, such as a refused linked source, an unreadable catalog, a throttled retry, a + * network fault, or a cancellation, proves nothing about the contract, so it fences nothing: an + * older acquisition that verifies a contract afterwards may still publish it. + * + * A superseded acquisition never returns its own result. It returns `latest`: the newer contract, + * the newer definitive outcome, or, after an invalidation, Unavailable with + * [ContractUnavailableReason.NoUsableContract], because the contract proved unusable. + * + * Bookkeeping is in memory, per acquirer, under one lock, and never waits for an acquisition, so a + * cancelled or failed acquisition cannot hold back later ones. A definitive no-contract result does + * not remove a previously cached contract: a refresh that finds nothing keeps the cached one. */ internal class ContractPublicationFence { - private class Generations { - /** The newest generation handed to an acquisition. */ + private class State { var started = 0L - - /** Results from this generation or older are no longer published. */ var fenced = 0L + var latest: ContractAcquisitionOutcome? = null } - private val generations = HashMap() + private val states = HashMap() /** Starts an acquisition for [request] and returns its generation. */ - fun start(request: ContractAcquisitionRequest): Long = synchronized(generations) { - generations.getOrPut(request, ::Generations).let { entry -> ++entry.started } + fun start(request: ContractAcquisitionRequest): Long = synchronized(states) { + states.getOrPut(request, ::State).let { state -> ++state.started } + } + + /** + * Publishes [contract] with [store] unless [generation] is superseded. Returns the outcome the + * acquisition reports: its own contract, or the newer accepted result. + */ + fun publish( + request: ContractAcquisitionRequest, + generation: Long, + contract: VerifiedOpenApiContract, + store: (VerifiedOpenApiContract) -> Unit, + ): ContractAcquisitionOutcome = synchronized(states) { + val state = states.getOrPut(request, ::State) + if (generation <= state.fenced) return state.supersededOutcome() + store(contract) + ContractAcquisitionOutcome.Acquired(contract).also { accepted -> state.accept(generation, accepted) } } /** - * Runs [publish] and returns true when [generation] is newer than the last publication or - * invalidation for [request]. Returns false, without publishing, for a superseded result. + * Records an acquisition that ended without a contract. A definitive outcome fences every older + * generation; a transient one changes nothing. Returns the outcome the acquisition reports. */ - fun publishIfCurrent(request: ContractAcquisitionRequest, generation: Long, publish: () -> Unit): Boolean = - synchronized(generations) { - val entry = generations.getOrPut(request, ::Generations) - if (generation <= entry.fenced) return false - entry.fenced = generation - publish() - true - } - - /** Runs [discard] and fences out every acquisition for [request] that has already started. */ - fun invalidate(request: ContractAcquisitionRequest, discard: () -> T): T = synchronized(generations) { - val entry = generations.getOrPut(request, ::Generations) - entry.fenced = maxOf(entry.fenced, entry.started) + fun complete( + request: ContractAcquisitionRequest, + generation: Long, + outcome: ContractAcquisitionOutcome.Unavailable, + ): ContractAcquisitionOutcome = synchronized(states) { + if (!outcome.reason.isDefinitive()) return outcome + val state = states.getOrPut(request, ::State) + if (generation <= state.fenced) return state.supersededOutcome() + outcome.also { accepted -> state.accept(generation, accepted) } + } + + /** Runs [discard] and supersedes every acquisition for [request] that has already started. */ + fun invalidate(request: ContractAcquisitionRequest, discard: () -> T): T = synchronized(states) { + val state = states.getOrPut(request, ::State) + state.fenced = maxOf(state.fenced, state.started) + state.latest = INVALIDATED discard() } + + private fun State.accept(generation: Long, outcome: ContractAcquisitionOutcome) { + fenced = generation + latest = outcome + } + + /** Only reached after an accepted result or an invalidation, so `latest` is always set. */ + private fun State.supersededOutcome(): ContractAcquisitionOutcome = checkNotNull(latest) + + /** Whether a no-contract reason is evidence about the release, rather than a failure to look. */ + private fun ContractUnavailableReason.isDefinitive(): Boolean = when (this) { + ContractUnavailableReason.NoMatchingRelease, + ContractUnavailableReason.NoUsableContract, + ContractUnavailableReason.MalformedSource, + -> true + ContractUnavailableReason.SourceTemporarilyUnavailable, + ContractUnavailableReason.UnreadableCatalog, + ContractUnavailableReason.RecentlyChecked, + -> false + } + + private companion object { + val INVALIDATED = ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoUsableContract) + } } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index b7067bfe2..7515898c7 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -75,7 +75,11 @@ class SignedAppStoreContractAcquirer( } return try { val generation = publicationFence.start(request) - acquireUncoalesced(request, cachePolicy, generation).also { acquired -> owner.complete(acquired) } + // Contracts are published through the fence; a no-contract ending is recorded here. + when (val outcome = acquireUncoalesced(request, cachePolicy, generation)) { + is ContractAcquisitionOutcome.Unavailable -> publicationFence.complete(request, generation, outcome) + is ContractAcquisitionOutcome.Acquired -> outcome + }.also { acquired -> owner.complete(acquired) } } catch (failure: Throwable) { owner.completeExceptionally(failure) throw failure @@ -219,19 +223,16 @@ class SignedAppStoreContractAcquirer( ) /** - * Publishes [contract] unless an acquisition that started later, or an invalidation, already - * superseded [generation]. A superseded caller receives the newer contract when one is published. + * Publishes [contract] unless [generation] is superseded. A superseded acquisition never reports + * its own rejected contract; it reports the newer accepted result. See [ContractPublicationFence]. */ private fun cacheContract( request: ContractAcquisitionRequest, generation: Long, contract: VerifiedOpenApiContract, - ): ContractAcquisitionOutcome { - val published = publicationFence.publishIfCurrent(request, generation) { - verifiedContracts[request] = contract - runCatching { verifiedContractCache.store(request, contract) } - } - return ContractAcquisitionOutcome.Acquired(if (published) contract else verifiedContracts[request] ?: contract) + ): ContractAcquisitionOutcome = publicationFence.publish(request, generation, contract) { accepted -> + verifiedContracts[request] = accepted + runCatching { verifiedContractCache.store(request, accepted) } } private fun acquireGitHubTagContract( diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt index b41e12321..2a897042b 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFenceTest.kt @@ -11,7 +11,6 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -20,6 +19,15 @@ import mockwebserver3.MockWebServer import okhttp3.OkHttpClient class ContractPublicationFenceTest { + private companion object { + val DEFINITIVE = listOf( + ContractUnavailableReason.NoMatchingRelease, + ContractUnavailableReason.NoUsableContract, + ContractUnavailableReason.MalformedSource, + ) + val TRANSIENT = ContractUnavailableReason.entries - DEFINITIVE.toSet() + } + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") private val executor: ExecutorService = Executors.newSingleThreadExecutor() private val cache = MemoryVerifiedContractCache() @@ -27,6 +35,7 @@ class ContractPublicationFenceTest { private val firstEntered = CountDownLatch(1) private val releaseFirst = CountDownLatch(1) private var firstFails = false + private var cancelVerification: Int? = null private var now = 50_000_000L @AfterTest @@ -36,19 +45,46 @@ class ContractPublicationFenceTest { } @Test - fun `generations publish in start order and invalidation fences running acquisitions`() { + fun `the fence follows its state table for every kind of ending`() { val fence = ContractPublicationFence() + val published = mutableListOf() + fun publish(generation: Long, version: Int) = + fence.publish(request, generation, contract(version)) { published += it.document } + + // A newer contract supersedes an older one, which reports the newer contract. val older = fence.start(request) val newer = fence.start(request) - assertTrue(fence.publishIfCurrent(request, newer) {}) - assertFalse(fence.publishIfCurrent(request, older) { error("A superseded result must not publish.") }) + assertEquals(contract(2), acquired(publish(newer, 2))) + assertEquals(contract(2), acquired(publish(older, 1))) + assertEquals(listOf(document(2)), published) + + // Transient endings and cancellation of a newer generation fence nothing. + val waiting = fence.start(request) + for (reason in TRANSIENT) { + val transient = ContractAcquisitionOutcome.Unavailable(reason) + assertEquals(transient, fence.complete(request, fence.start(request), transient)) + } + fence.start(request) // A cancelled generation never reports back. + assertEquals(contract(3), acquired(publish(waiting, 3))) + + // A definitive no-contract ending supersedes older generations, which report it. + for (reason in DEFINITIVE) { + val stale = fence.start(request) + val definitive = ContractAcquisitionOutcome.Unavailable(reason) + assertEquals(definitive, fence.complete(request, fence.start(request), definitive)) + assertEquals(definitive, publish(stale, 9)) + assertEquals(definitive, fence.complete(request, stale, unavailable(ContractUnavailableReason.NoUsableContract))) + } + assertEquals(listOf(document(2), document(3)), published) + // Invalidation supersedes every started generation; later ones publish normally. val running = fence.start(request) fence.invalidate(request) {} - assertFalse(fence.publishIfCurrent(request, running) { error("An invalidated result must not publish.") }) - assertTrue(fence.publishIfCurrent(request, fence.start(request)) {}) + assertEquals(unavailable(ContractUnavailableReason.NoUsableContract), publish(running, 4)) + assertEquals(contract(5), acquired(publish(fence.start(request), 5))) // Requests are fenced independently. - assertTrue(fence.publishIfCurrent(request.copy(installedAppVersion = "4.0.3"), 1) {}) + assertEquals(contract(6), acquired(fence.publish(request.copy(installedAppVersion = "4.0.3"), 1, contract(6)) {})) + assertEquals(listOf(document(2), document(3), document(5)), published) } @Test @@ -120,7 +156,8 @@ class ContractPublicationFenceTest { assertEquals(VerifiedContractRemoval.Removed, acquirer.invalidateVerifiedContract(request)) releaseFirst.countDown() - running.get(10, TimeUnit.SECONDS) + // The rejected contract is never reported as acquired. + assertEquals(unavailable(ContractUnavailableReason.NoUsableContract), running.get(10, TimeUnit.SECONDS)) assertNull(cache.load(request)) // The next automatic discovery acquires again; the catalog is still cached. @@ -130,6 +167,64 @@ class ContractPublicationFenceTest { } } + @Test + fun `a definitive no-contract refresh supersedes an older acquisition`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val stale = startBlockedAcquisition(server, acquirer) + + // Neither refreshed catalog lists the app any more. + repeat(2) { server.enqueue(MockResponse(body = "[]")) } + val noRelease = unavailable(ContractUnavailableReason.NoMatchingRelease) + assertEquals(noRelease, acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh)) + releaseFirst.countDown() + + assertEquals(noRelease, stale.get(10, TimeUnit.SECONDS)) + assertNull(cache.load(request)) + } + } + + @Test + fun `a transient refresh failure leaves an older acquisition free to publish`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val older = startBlockedAcquisition(server, acquirer) + + server.enqueue(MockResponse(code = 503, body = "unavailable")) + assertFailsWith { acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh) } + now += 61_000L + // Both catalogs are unreadable, which proves nothing about the contract. + repeat(2) { server.enqueue(MockResponse(body = "[truncated")) } + assertEquals( + unavailable(ContractUnavailableReason.UnreadableCatalog), + acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh), + ) + releaseFirst.countDown() + + assertEquals(document(1), document(older.get(10, TimeUnit.SECONDS))) + assertEquals(document(1), cache.load(request)?.document) + } + } + + @Test + fun `a cancelled newer acquisition leaves an older acquisition free to publish`() { + MockWebServer().use { server -> + server.start() + cancelVerification = 2 + val acquirer = acquirer(server) + val older = startBlockedAcquisition(server, acquirer) + + enqueueCatalogAndPackage(server) + assertFailsWith { acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh) } + releaseFirst.countDown() + + assertEquals(document(1), document(older.get(10, TimeUnit.SECONDS))) + assertEquals(document(1), cache.load(request)?.document) + } + } + /** Starts an automatic acquisition and waits until it is blocked verifying its package. */ private fun startBlockedAcquisition( server: MockWebServer, @@ -143,6 +238,23 @@ class ContractPublicationFenceTest { private fun document(version: Int) = "{\"version\":$version}" + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun acquired(outcome: ContractAcquisitionOutcome) = assertIs(outcome).contract + + private fun contract(version: Int) = VerifiedOpenApiContract( + appId = "cospend", + appVersion = "4.0.2", + contractVersion = "4.0.2", + specFile = "openapi.json", + document = document(version), + catalogUrl = "https://fixture.invalid/apps.json", + packageUrl = "https://fixture.invalid/cospend.tar.gz", + sourceUrl = "https://fixture.invalid/cospend.tar.gz#openapi.json", + sourceKind = OpenApiContractSourceKind.SignedAppPackage, + contractKind = VerifiedContractKind.OpenApi, + ) + private fun document(outcome: ContractAcquisitionOutcome): String = assertIs(outcome).contract.document @@ -166,6 +278,7 @@ class ContractPublicationFenceTest { check(releaseFirst.await(10, TimeUnit.SECONDS)) { "The blocked verification was never released." } if (firstFails) throw CancellationException("synthetic cancellation") } + if (call == cancelVerification) throw CancellationException("synthetic cancellation") return VerifiedPackageContract(release.appId, release.version, "openapi.json", document(call)) } }, From ea83ab908fbdd093e80d2f19d31f26b2cd83d522 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 15:06:30 +0200 Subject: [PATCH 43/49] fix(contracts): classify bundled trust resource read failures as client faults A bundled root certificate or revocation list stream that fails while it is read now throws ContractTrustMaterialInvalidException with the read failure as its cause, instead of escaping as an IOException that would be classified as a network failure. --- .../NextcloudAppPackageTrustVerifier.kt | 18 ++++++++++++++-- .../contracts/BundledTrustMaterialTest.kt | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt index 2600a68b3..45d0d8d67 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/NextcloudAppPackageTrustVerifier.kt @@ -1,7 +1,9 @@ package dev.obiente.nextcloudnative.contracts import java.io.ByteArrayInputStream +import java.io.IOException import java.io.InputStream +import java.io.UncheckedIOException import java.security.GeneralSecurityException import java.security.Signature import java.security.cert.CertificateFactory @@ -49,8 +51,8 @@ class NextcloudAppPackageTrustVerifier internal constructor( * [ContractTrustResourceMissingException]; it never describes the downloaded package. */ private fun loadTrustMaterial(): TrustMaterial { - val certificateBytes = trustResourceLoader(ROOT_CERTIFICATES_RESOURCE) - val revocationBytes = trustResourceLoader(ROOT_REVOCATION_LIST_RESOURCE) + val certificateBytes = readTrustResource(ROOT_CERTIFICATES_RESOURCE) + val revocationBytes = readTrustResource(ROOT_REVOCATION_LIST_RESOURCE) return try { val factory = CertificateFactory.getInstance("X.509") val trusted = factory.generateCertificates(ByteArrayInputStream(certificateBytes)) @@ -75,6 +77,18 @@ class NextcloudAppPackageTrustVerifier internal constructor( } } + /** + * Reads one bundled resource. A missing resource keeps its own exception; a stream that fails + * while it is read is local installation or storage damage, never a network failure. + */ + private fun readTrustResource(name: String): ByteArray = try { + trustResourceLoader(name) + } catch (failure: IOException) { + throw ContractTrustMaterialInvalidException(failure) + } catch (failure: UncheckedIOException) { + throw ContractTrustMaterialInvalidException(failure) + } + private data class TrustMaterial( val factory: CertificateFactory, val trustedCertificates: List, diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt index 7d636cf34..13d781f1b 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/BundledTrustMaterialTest.kt @@ -1,10 +1,13 @@ package dev.obiente.nextcloudnative.contracts +import java.io.IOException +import java.io.InputStream import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import mockwebserver3.MockResponse import mockwebserver3.MockWebServer import okhttp3.OkHttpClient @@ -34,6 +37,24 @@ class BundledTrustMaterialTest { assertClientFault(verifier(roots = revocations)) } + @Test + fun `a bundled resource that fails while it is read is a client fault, not a network failure`() { + val failing = NextcloudAppPackageTrustVerifier(trustResourceLoader = { name -> + if (name == ROOTS) { + roots + } else { + object : InputStream() { + private var served = 0 + + override fun read(): Int = if (served++ < 16) 'x'.code else throw IOException("synthetic read failure") + }.readBytes() + } + }) + val failure = assertFailsWith { acquireWith(failing) } + assertIs(failure.cause) + assertEquals(ContractAcquisitionFailureKind.RuntimeIncompatible, classifyContractAcquisitionFailure(failure)) + } + @Test fun `a package certificate that fails identity checks is still a verification failure`() { // A well-formed certificate from the bundled chain, presented for another app. From 54436baff5520c238e841669eb4c940d789bf6df Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 15:06:32 +0200 Subject: [PATCH 44/49] fix(contracts): derive no-contract reasons from one record of inspected evidence ContractAcquisitionEvidence records catalog readability, dropped entries, unidentified top-level catalog elements, linked-source refusals, and inspected candidates, and is the only source of the no-contract reason. A malformed patch-compatible entry now outranks NoUsableContract after a valid candidate is exhausted, and a catalog element that is not an object or lacks a string id makes absence unproven instead of NoMatchingRelease; a catalog made only of such elements is unreadable. The scattered unmatchedReleaseOutcome and uninspectedExactReleaseReason special cases are removed. --- .../contracts/AppStoreCatalogReleases.kt | 73 ++++------ .../contracts/ContractAcquisitionEvidence.kt | 68 ++++++++++ .../contracts/ContractAcquisitionOutcome.kt | 24 ---- .../SignedAppStoreContractAcquirer.kt | 14 +- .../contracts/AcquisitionEvidenceTest.kt | 126 ++++++++++++++++++ 5 files changed, 227 insertions(+), 78 deletions(-) create mode 100644 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionEvidence.kt create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionEvidenceTest.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt index f6ad21ff5..eddc7e6de 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/AppStoreCatalogReleases.kt @@ -8,8 +8,17 @@ import org.json.JSONObject /** Which required catalog value made an entry for the requested app unusable. */ internal enum class MalformedCatalogField { - /** The catalog document itself is not a readable JSON array. */ + /** + * The catalog document itself is not a readable JSON array, or it is a non-empty array in + * which no element is an app object with a readable `id`. + */ Document, + + /** + * The app was not found, and some top-level element is not an object or has a missing or + * wrong-typed `id`, so it may have been the requested app. + */ + App, Releases, Entry, Version, @@ -48,47 +57,6 @@ internal data class CatalogReleases( val documentReadable: Boolean get() = malformed.none { entry -> entry.field == MalformedCatalogField.Document } } -/** - * Explains an empty release selection. A dropped entry that would have been selected, or one whose - * version cannot be read, means the catalog is malformed for this app rather than lacking a release. - * Compatible candidates come only from the platform catalog; the full catalog supplies exact matches. - */ -internal fun unmatchedReleaseOutcome( - installedVersion: String?, - platformCatalog: CatalogReleases, - allCatalog: CatalogReleases?, -): ContractAcquisitionOutcome.Unavailable { - if (!platformCatalog.documentReadable || allCatalog?.documentReadable == false) { - return ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.UnreadableCatalog) - } - fun MalformedCatalogEntry.isExact(): Boolean = version == null || version == installedVersion - val malformedMatch = platformCatalog.malformed.any { entry -> - entry.isExact() || isCompatibleReleaseVersion(requireNotNull(entry.version), installedVersion) - } || allCatalog?.malformed.orEmpty().any { entry -> entry.isExact() } - return ContractAcquisitionOutcome.Unavailable( - if (malformedMatch) ContractUnavailableReason.MalformedSource else ContractUnavailableReason.NoMatchingRelease, - ) -} - -/** - * Why the exact installed release could not be inspected: its catalog was unreadable, or an entry - * that may be the exact release was malformed. Null when the catalogs were readable and simply do - * not list it. - */ -internal fun uninspectedExactReleaseReason( - installedVersion: String?, - platformCatalog: CatalogReleases, - allCatalog: CatalogReleases?, -): ContractUnavailableReason? { - if (installedVersion == null) return null - if (!platformCatalog.documentReadable || allCatalog?.documentReadable == false) { - return ContractUnavailableReason.UnreadableCatalog - } - val malformedExact = (platformCatalog.malformed + allCatalog?.malformed.orEmpty()) - .any { entry -> entry.version == null || entry.version == installedVersion } - return if (malformedExact) ContractUnavailableReason.MalformedSource else null -} - internal fun selectRelease( catalogJson: String, request: ContractAcquisitionRequest, @@ -122,11 +90,22 @@ internal fun parseReleases( } catch (_: JSONException) { return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, MalformedCatalogField.Document))) } - val app = (0 until apps.length()) - .asSequence() - .mapNotNull(apps::optJSONObject) + var unidentified = 0 + val app = (0 until apps.length()).asSequence() + .mapNotNull { index -> + apps.optJSONObject(index)?.takeIf { candidate -> candidate.stringValue("id") != null } + .also { identified -> if (identified == null) unidentified += 1 } + } .firstOrNull { candidate -> candidate.stringValue("id") == appId } - ?: return CatalogReleases(emptyList()) + if (app == null) { + // An element whose identity cannot be read may have been the requested app, so absence is unproven. + val field = when (unidentified) { + 0 -> return CatalogReleases(emptyList()) + apps.length() -> MalformedCatalogField.Document + else -> MalformedCatalogField.App + } + return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, field))) + } val releases = app.optJSONArray("releases") ?: return CatalogReleases(emptyList(), listOf(MalformedCatalogEntry(null, MalformedCatalogField.Releases))) val certificate = app.stringValue("certificate")?.takeIf(String::isNotBlank) @@ -231,7 +210,7 @@ internal fun compatibleReleaseCandidates( } /** A non-prerelease with the installed major and minor version, or any non-prerelease when unknown. */ -private fun isCompatibleReleaseVersion(version: String, installedVersion: String?): Boolean { +internal fun isCompatibleReleaseVersion(version: String, installedVersion: String?): Boolean { if (version.isRecognizedPrerelease()) return false if (installedVersion == null) return true val installed = parseVersion(installedVersion) ?: return false diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionEvidence.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionEvidence.kt new file mode 100644 index 000000000..9704627ec --- /dev/null +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionEvidence.kt @@ -0,0 +1,68 @@ +package dev.obiente.nextcloudnative.contracts + +/** What inspecting one candidate release found, when it did not end the acquisition with a contract. */ +internal enum class InspectedCandidateResult { + /** The signed package has no contract; a linked source may still supply one. */ + PackageWithoutContract, + + /** The signed package supplied only verified read routes. */ + ReadRoutesOnly, +} + +/** + * Everything one acquisition inspected for the requested app: each catalog's readability and + * dropped entries ([CatalogReleases.malformed], including top-level elements whose identity could + * not be read), the candidates inspected and what each provided, and whether a linked source + * refused a request. The no-contract reason is derived only from this record, in this order: + * + * 1. a linked source refused or limited a request: [ContractUnavailableReason.SourceTemporarilyUnavailable], + * because a contract may exist there; + * 2. a catalog document could not be read: [ContractUnavailableReason.UnreadableCatalog]; + * 3. malformed evidence that could have matched: a dropped entry for the exact version or with an + * unreadable version in either catalog, a dropped patch-compatible entry in the platform catalog + * (the only source of compatible candidates), an app without a readable release list, or a + * top-level element whose identity could not be read where the app was not found: + * [ContractUnavailableReason.MalformedSource]; + * 4. every candidate that could have matched was inspected and none provided a contract: + * [ContractUnavailableReason.NoUsableContract]; + * 5. the catalogs were fully readable, nothing relevant was dropped, and nothing matched: + * [ContractUnavailableReason.NoMatchingRelease]. + * + * Steps 4 and 5 claim that nothing usable exists, so they are reached only when steps 1 to 3 found + * nothing that was left uninspected. + */ +internal class ContractAcquisitionEvidence( + private val installedVersion: String?, + private val platformCatalog: CatalogReleases, + private val fullCatalog: CatalogReleases?, +) { + private var sourceTemporarilyUnavailable = false + private val inspected = mutableListOf>() + + fun markSourceTemporarilyUnavailable() { + sourceTemporarilyUnavailable = true + } + + fun recordInspected(release: AppStoreRelease, result: InspectedCandidateResult) { + inspected += release.version to result + } + + fun unavailable(): ContractAcquisitionOutcome.Unavailable = ContractAcquisitionOutcome.Unavailable(reason()) + + private fun reason(): ContractUnavailableReason = when { + sourceTemporarilyUnavailable -> ContractUnavailableReason.SourceTemporarilyUnavailable + !platformCatalog.documentReadable || fullCatalog?.documentReadable == false -> + ContractUnavailableReason.UnreadableCatalog + hasRelevantMalformedEvidence() -> ContractUnavailableReason.MalformedSource + inspected.isNotEmpty() -> ContractUnavailableReason.NoUsableContract + else -> ContractUnavailableReason.NoMatchingRelease + } + + private fun hasRelevantMalformedEvidence(): Boolean = + platformCatalog.malformed.any { entry -> + entry.couldBeExact() || isCompatibleReleaseVersion(requireNotNull(entry.version), installedVersion) + } || fullCatalog?.malformed.orEmpty().any { entry -> entry.couldBeExact() } + + /** An entry without a readable version, including an unidentified app, could be the exact release. */ + private fun MalformedCatalogEntry.couldBeExact(): Boolean = version == null || version == installedVersion +} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt index a138714a3..30407cd85 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionOutcome.kt @@ -52,27 +52,3 @@ internal fun refreshShortcut( catalog.source == CatalogSource.RefreshRefused -> ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.RecentlyChecked) else -> null } - -/** Evidence gathered by one uncoalesced acquisition that decides its no-contract reason. */ -/** - * Records what inspecting candidate releases found. When no candidate provides a contract, a - * temporarily unavailable linked source wins, because a contract may still exist; then an exact - * installed release that could not be inspected ([exactReleaseUninspected]); and only then - * [ContractUnavailableReason.NoUsableContract], which claims the inspected releases provide no API. - */ -internal class ContractAcquisitionAttempt(private val exactReleaseUninspected: ContractUnavailableReason? = null) { - var sourceTemporarilyUnavailable: Boolean = false - private set - - fun markSourceTemporarilyUnavailable() { - sourceTemporarilyUnavailable = true - } - - fun unavailable(): ContractAcquisitionOutcome.Unavailable = ContractAcquisitionOutcome.Unavailable( - when { - sourceTemporarilyUnavailable -> ContractUnavailableReason.SourceTemporarilyUnavailable - exactReleaseUninspected != null -> exactReleaseUninspected - else -> ContractUnavailableReason.NoUsableContract - }, - ) -} diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 7515898c7..187c8b33d 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -120,11 +120,9 @@ class SignedAppStoreContractAcquirer( installedVersion = request.installedAppVersion, ).filterNot { candidate -> candidate.version == exactRelease?.version } val releases = listOfNotNull(exactRelease) + compatibleReleases - if (releases.isEmpty()) return unmatchedReleaseOutcome(request.installedAppVersion, platformCatalog, allCatalog) - val attempt = ContractAcquisitionAttempt( - exactReleaseUninspected = uninspectedExactReleaseReason(request.installedAppVersion, platformCatalog, allCatalog) - .takeIf { exactRelease == null }, - ) + // Every no-contract reason below is derived from this record of what was actually inspected. + val attempt = ContractAcquisitionEvidence(request.installedAppVersion, platformCatalog, allCatalog) + if (releases.isEmpty()) return attempt.unavailable() val missingPackageContracts = mutableListOf() val verifiedRouteFallbacks = mutableListOf>() @@ -142,6 +140,7 @@ class SignedAppStoreContractAcquirer( val verified = try { trustVerifier.verifyAndExtract(release, archive) } catch (_: OpenApiContractMissingException) { + attempt.recordInspected(release, InspectedCandidateResult.PackageWithoutContract) missingPackageContracts += release continue } catch (failure: GeneralSecurityException) { @@ -176,6 +175,7 @@ class SignedAppStoreContractAcquirer( linkedContract.mergeVerifiedPackageRoutes(verified), ) } + attempt.recordInspected(release, InspectedCandidateResult.ReadRoutesOnly) verifiedRouteFallbacks += release to verified continue } @@ -239,7 +239,7 @@ class SignedAppStoreContractAcquirer( release: AppStoreRelease, installedAppVersion: String?, exact: Boolean, - attempt: ContractAcquisitionAttempt, + attempt: ContractAcquisitionEvidence, ): VerifiedOpenApiContract? { release.githubSources.forEach sourceLoop@ { source -> val sourceRoot = "${rawGitHubBaseUrl.trimEnd('/')}/${source.owner}/${source.repository}/${source.tag}" @@ -314,7 +314,7 @@ class SignedAppStoreContractAcquirer( source: GitHubSourceReference, release: AppStoreRelease, sourceRoot: String, - attempt: ContractAcquisitionAttempt, + attempt: ContractAcquisitionEvidence, ): OpenApiCandidate? { val archiveUrl = "${codeloadGitHubBaseUrl.trimEnd('/')}/${source.owner}/${source.repository}" + "/tar.gz/refs/tags/${source.tag}" diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionEvidenceTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionEvidenceTest.kt new file mode 100644 index 000000000..28ba41c43 --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionEvidenceTest.kt @@ -0,0 +1,126 @@ +package dev.obiente.nextcloudnative.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +/** The installed version is 4.0.2. No candidate package in these tests provides a contract. */ +class AcquisitionEvidenceTest { + @Test + fun `a malformed compatible entry outranks a valid compatible candidate without a contract`() { + val platform = catalog(release("4.0.1", server = true), release("4.0.0").also { it.remove("signature") }) + assertEquals(malformedSource, acquire(platform, full = catalog(release("4.0.1")))) + } + + @Test + fun `a malformed compatible entry outranks an inspected exact release without a contract`() { + val platform = catalog(release("4.0.2", server = true), release("4.0.1").also { it.remove("download") }) + assertEquals(malformedSource, acquire(platform, full = null)) + } + + @Test + fun `every inspected candidate without a contract and nothing dropped is no usable contract`() { + val platform = catalog(release("4.0.2", server = true), release("4.0.1", server = true)) + assertEquals(unavailable(ContractUnavailableReason.NoUsableContract), acquire(platform, full = null, packages = 2)) + } + + @Test + fun `an unidentified top-level element where the app is missing is malformed, not absence`() { + val other = JSONObject().put("id", "other").put("releases", JSONArray()) + for (unidentified in listOf("junk", 7, JSONObject().put("id", 42), JSONObject().put("name", "cospend"))) { + val full = JSONArray().put(other).put(unidentified) + assertEquals(malformedSource, acquire(JSONArray().put(other), full = full, packages = 0), unidentified.toString()) + } + // A catalog that simply does not list the app is still genuine absence. + assertEquals( + unavailable(ContractUnavailableReason.NoMatchingRelease), + acquire(JSONArray().put(other), full = JSONArray().put(other), packages = 0), + ) + } + + @Test + fun `a catalog made only of unidentified elements is unreadable and never cached`() { + for (body in listOf("""["junk", 7]""", """[{"id": 42}, {"name": "cospend"}, null]""")) { + val parsed = parseReleases(body, "cospend", "https://fixture.invalid/apps.json", false) + assertEquals(listOf(MalformedCatalogEntry(null, MalformedCatalogField.Document)), parsed.malformed, body) + assertFalse(parsed.documentReadable, body) + } + val unreadable = unavailable(ContractUnavailableReason.UnreadableCatalog) + assertEquals(unreadable, acquireRaw(platform = """["junk", 7]""", full = """["junk", 7]""")) + } + + @Test + fun `an unidentified element beside a valid matching release is ignored`() { + val platform = JSONArray().put("junk").put(JSONObject().put("id", 42)).put(app(release("4.0.2", server = true))) + assertEquals(emptyList(), parseReleases(platform.toString(), "cospend", "https://fixture.invalid/apps.json", false).malformed) + val outcome = acquire(platform, full = null) { release -> + VerifiedPackageContract(release.appId, release.version, "openapi.json", "{}") + } + assertEquals("4.0.2", assertIs(outcome).contract.contractVersion) + } + + private val malformedSource = unavailable(ContractUnavailableReason.MalformedSource) + private var serverUrl = "" + + private fun acquire( + platform: JSONArray, + full: JSONArray?, + packages: Int = 1, + verify: (AppStoreRelease) -> VerifiedPackageContract = { throw OpenApiContractMissingException("synthetic") }, + ): ContractAcquisitionOutcome = withServer { server -> + server.enqueue(MockResponse(body = platform.toString().replace(SERVER, serverUrl))) + full?.let { server.enqueue(MockResponse(body = it.toString().replace(SERVER, serverUrl))) } + repeat(packages) { server.enqueue(MockResponse(body = "package")) } + acquirer(server, verify).acquireOutcome(request) + } + + private fun acquireRaw(platform: String, full: String): ContractAcquisitionOutcome = withServer { server -> + server.enqueue(MockResponse(body = platform)) + server.enqueue(MockResponse(body = full)) + acquirer(server) { error("No package may be verified.") }.acquireOutcome(request) + } + + private fun withServer(block: (MockWebServer) -> ContractAcquisitionOutcome): ContractAcquisitionOutcome = + MockWebServer().use { server -> + server.start() + serverUrl = server.url("downloads").toString() + block(server) + } + + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + + private fun unavailable(reason: ContractUnavailableReason) = ContractAcquisitionOutcome.Unavailable(reason) + + private fun app(vararg releases: JSONObject): JSONObject = + JSONObject().put("id", "cospend").put("certificate", "certificate").put("releases", JSONArray(releases.toList())) + + private fun catalog(vararg releases: JSONObject): JSONArray = JSONArray().put(app(*releases)) + + /** A release downloadable from the test server when [server] is true; otherwise a fixture host. */ + private fun release(version: String, server: Boolean = false): JSONObject = JSONObject() + .put("version", version) + .put("download", if (server) "$SERVER/cospend-$version.tar.gz" else "https://fixture.invalid/cospend-$version.tar.gz") + .put("signature", "c2lnbmF0dXJl") + .put("signatureDigest", "sha512") + .put("isNightly", false) + + private fun acquirer(server: MockWebServer, verify: (AppStoreRelease) -> VerifiedPackageContract) = + SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = verify(release) + }, + ) + + private companion object { + const val SERVER = "http://server.placeholder" + } +} From ba56fb576db44870af7cefd544f0fb5d42331e6d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 15:41:10 +0200 Subject: [PATCH 45/49] fix(ui): read contract strings only from JSON strings in the probe and compiler The same-origin probe accepts only a string openapi version and matches OCS API Viewer entries by a string id. The descriptor compiler's string reader no longer coerces numbers or booleans, so a non-string openapi version, operation ID, parameter name or location, schema type, format, or reference is ignored. The descriptor guide now states exactly which readers are strict, which compiler reads remain lenient, and how the no-contract reason follows from the inspected evidence. --- DYNAMIC_APP_DESCRIPTOR.md | 67 +++++++++---- .../app/DynamicSameOriginDiscovery.kt | 5 +- .../model/DynamicAppDescriptorCompiler.kt | 2 +- .../model/DynamicCompilerValueTypeTest.kt | 93 +++++++++++++++++++ .../app/DynamicSameOriginValueTypeTest.kt | 53 +++++++++++ 5 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicCompilerValueTypeTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index d4d320139..7b4190348 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -243,22 +243,56 @@ and the shared JVM boundary carries that reason to the fallback card. Catalog parsing records which required value made each dropped entry unusable. A required value must have its exact JSON type: a number, boolean, object, array, or `null` where a string belongs, or an `isNightly` that is not a boolean, is -malformed like a missing value and is never coerced. Contract documents are read -the same way, so a non-string `openapi` version, operation ID, parameter, or -reference is ignored, and a non-boolean `required` flag counts as required. A -dropped entry counts only when it would have been selected: the exact installed -version, a patch-compatible release, or an entry whose version cannot be read. +malformed like a missing value and is never coerced. + +These readers also require exact JSON types: + +- In the acquisition module's contract readers, a non-string `openapi` version, + operation ID, parameter name or location, or reference is ignored. A + non-boolean `required` flag counts as required. +- The same-origin probe accepts only a string `openapi` version, so a number + such as `3.0` is an unsupported contract. It also matches OCS API Viewer + entries by a string `id` only. +- The descriptor compiler reads string fields only from JSON strings. These + include `openapi`, operation IDs, parameter names and locations, schema types + and formats, summaries, descriptions, titles, and references. + +The descriptor compiler does not yet type-check boolean flags such as +`required`, `readOnly`, and `nullable`, or the items of string arrays: required +property lists, tags, `enum` values, and security scopes. Those still accept a +value of another primitive type and read it as text or as a boolean. + A catalog document that is truncated, empty, not JSON, or not an array is an -unreadable catalog, a reason of its own, because no entry could be read; it is -never cached, so checking again downloads it again. +unreadable catalog, a reason of its own, because no entry could be read. So is +a non-empty array in which no element is an app object with a string `id`. Such +a document is never cached, so checking again downloads it again. A corrupt or mismatched App Store-linked source archive yields no candidate, like an archive without a contract, instead of an unexpected client fault. -When the exact installed release cannot be inspected and the patch-compatible -candidates provide no contract, the result keeps the exact release's reason: -an unreadable catalog or a malformed exact entry outranks `NoUsableContract`, -which would claim that the matching releases provide no API. A linked source -that refused or rate limited a request still outranks both, because a contract -may exist there. + +[`ContractAcquisitionEvidence.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractAcquisitionEvidence.kt) +records what one acquisition actually inspected: + +- whether each catalog document was readable; +- the entries dropped as malformed, including a top-level element that is not + an object or has a missing or wrong-typed `id` while the app was not found, + because that element may have been the app; +- the candidates inspected, and what each provided; +- whether a linked source refused a request. + +The no-contract reason comes only from that record, in this order: + +1. A refused or rate-limited linked source, because a contract may exist there. +2. An unreadable catalog. +3. Malformed evidence that could have matched: an entry for the exact version + or with an unreadable version, a patch-compatible entry in the platform + catalog, an app without a readable release list, or an unidentified + top-level element. +4. No usable contract, only when every candidate that could have matched was + inspected. +5. No matching release, only when the catalogs were fully readable, nothing + relevant was dropped, and nothing matched. + +A dropped entry for an unrelated version never changes the reason. App Store catalogs are cached for six hours. The first discovery of an app screen uses the cache. A discovery started by, or after, an explicit retry on @@ -359,14 +393,15 @@ only genuine client faults remain unexpected. Size limits throw | Verified contract cache | Missing, expired, or unreadable entry | Ignored; acquisition continues | | Catalog and package requests | No response; non-success status; redirect away from HTTPS | Network; source unavailable; verification failed | | Catalog and package bodies | Declared or streamed size over the limit | Source unusable | -| Catalog document | Truncated, empty, not JSON, or not an array | Unreadable catalog, never cached | +| Catalog document | Truncated, empty, not JSON, not an array, or only elements without a readable app identity | Unreadable catalog, never cached | +| Catalog app element | Not an object, or an object with a missing or wrong-typed `id`, while the app is not found | Malformed source, not no matching release | | Catalog entry | Missing value, a required value of the wrong JSON type, or a download that is not an HTTP URL | Malformed source when it would be selected | -| Exact release after compatible candidates | Exact catalog unreadable or exact entry malformed, and no compatible candidate has a contract | Unreadable catalog or malformed source, not no usable contract | +| Candidates exhausted | A catalog was unreadable, or an exact, compatible, or unidentified entry was dropped, and no inspected candidate has a contract | Unreadable catalog or malformed source, not no usable contract | | Verified contract publication | A newer generation published a contract or ended without one definitively, or an invalidation happened | Not published; the newest accepted result is reported | | Catalog cache write | Local storage failure | Ignored | | Explicit retry inside the refresh window without a cached copy | None | Recently checked; no request | | Package verification | The package's signature, certificate, chain, revocation, identity, archive structure, or app metadata rejected | Verification failed | -| Package verification | Bundled root certificate or revocation list missing, corrupt, expired, of an unsupported format, or inconsistent | Client fault (runtime incompatible); retrying cannot repair it | +| Package verification | Bundled root certificate or revocation list missing, unreadable, corrupt, expired, of an unsupported format, or inconsistent | Client fault (runtime incompatible); retrying cannot repair it | | Package archive limits | Entry, expanded, path, or metadata-count limit exceeded | Source unusable | | Package OpenAPI JSON or YAML | Unreadable or not OpenAPI 3 | Candidate skipped | | Linked metadata, tree, spec, and schema requests | 404; size over the limit; other status; no response | Skipped; skipped; source unavailable; network | diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index df8d75f09..179d8835c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -264,7 +264,7 @@ private suspend fun discoverOfficialViewerSpecIds( } val appEntry = catalog .mapNotNull { it as? JsonObject } - .firstOrNull { entry -> (entry["id"] as? JsonPrimitive)?.contentOrNull == appId } + .firstOrNull { entry -> (entry["id"] as? JsonPrimitive)?.takeIf { it.isString }?.content == appId } if (appEntry == null) { diagnostics += "OCS API Viewer does not advertise an OpenAPI specification for $appId." return emptyList() @@ -296,7 +296,8 @@ private fun dynamicDiscoveryRequest(path: String): NextcloudApiRequest = Nextclo private fun NextcloudApiResponse.parseOpenApiDocument(): JsonObject? { val document = runCatching { dynamicJson.parseToJsonElement(body.decodeToString()) as? JsonObject } .getOrNull() ?: return null - val version = (document["openapi"] as? JsonPrimitive)?.contentOrNull + // Only a JSON string declares the version; a number such as 3.0 is not coerced into one. + val version = (document["openapi"] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.content return document.takeIf { version?.startsWith("3.") == true } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt index c6ee2b09a..41c335512 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt @@ -2615,7 +2615,7 @@ internal fun String.humanize(): String = buildString(length + 4) { .filter(String::isNotBlank) .joinToString(" ") { word -> word.replaceFirstChar(Char::uppercaseChar) } -private fun JsonObject.string(key: String): String? = (get(key) as? JsonPrimitive)?.contentOrNull +private fun JsonObject.string(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content private fun JsonObject.boolean(key: String): Boolean? = (get(key) as? JsonPrimitive)?.booleanOrNull diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicCompilerValueTypeTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicCompilerValueTypeTest.kt new file mode 100644 index 000000000..4a9b3ee7d --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicCompilerValueTypeTest.kt @@ -0,0 +1,93 @@ +package dev.obiente.nextcloudnative.nativeui.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** String fields of an OpenAPI document are read only from JSON strings, never coerced from other primitives. */ +class DynamicCompilerValueTypeTest { + @Test + fun `an openapi version that is not a string is unsupported`() { + for (version in listOf("3.0", "3", "true")) { + assertFailsWith(version) { + compile(contract(openapi = version)) + } + } + assertTrue(compile(contract(openapi = "\"3.0.3\"")).actions.isNotEmpty()) + } + + @Test + fun `a non-string operation id never names an operation`() { + for (operationId in listOf("7", "true", "1.5")) { + val descriptor = compile(contract(getOperationId = operationId, postOperationId = operationId)) + // A write without a readable operation ID is ignored; a read gets a derived ID instead. + assertFalse(descriptor.actions.any { it.binding.method == HttpMethod.POST }, operationId) + assertTrue(descriptor.warnings.any { it.code == "ignored-unnamed-write" }, operationId) + val read = descriptor.actions.single { it.binding.method == HttpMethod.GET } + assertFalse(read.id == operationId.trim('"'), operationId) + } + val named = compile(contract()) + assertTrue(named.actions.any { it.id == "items-create" }) + } + + @Test + fun `parameters with a non-string name or location are not declared`() { + val parameters = """ + {"name":"id","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":7,"in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"filter","in":1,"schema":{"type":"string"}}, + {"name":"search","in":"query","schema":{"type":"string"}} + """ + val descriptor = compile(contract(path = "/items/{id}/notes", parameters = parameters)) + val read = descriptor.actions.single { it.binding.method == HttpMethod.GET } + assertEquals(listOf("id"), read.binding.pathParameters.map { it.name }) + assertEquals(listOf("search"), read.binding.queryParameters.map { it.name }) + } + + private fun compile(document: String): DynamicAppDescriptor = DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("items", "Items", "1.0.0"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/apps/items/api")), + advertisedOpenApi = AdvertisedOpenApi( + "/apps/items/openapi.json", + Json.parseToJsonElement(document), + OpenApiTrust.sameOriginAdvertisement, + ), + ), + ) + + private fun contract( + openapi: String = "\"3.0.3\"", + getOperationId: String = "\"items-list\"", + postOperationId: String = "\"items-create\"", + path: String = "/items", + parameters: String = "", + ): String = """ + { + "openapi": $openapi, + "info": {"title": "Items", "version": "1.0.0"}, + "paths": { + "/apps/items/api$path": { + "parameters": [$parameters], + "get": { + "operationId": $getOperationId, + "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": { + "type": "array", + "items": {"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}} + }}}}} + }, + "post": { + "operationId": $postOperationId, + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", "properties": {"name": {"type": "string"}} + }}}}, + "responses": {"200": {"description": "OK"}} + } + } + } + } + """.trimIndent() +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt new file mode 100644 index 000000000..7ea46c8d0 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt @@ -0,0 +1,53 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DynamicSameOriginValueTypeTest { + private val app = NextcloudAppEntry("example", "Example", "/apps/example/") + + @Test + fun `a server contract whose openapi version is not a string is not imported`() = runBlocking { + for (version in listOf("3.0", "3", "true")) { + val discovery = discover(spec(version)) + assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition, version) + assertEquals(DynamicContractFallbackReason.UnsupportedContract, discovery.fallbackReason, version) + assertTrue(discovery.diagnostics.any { "invalid OpenAPI JSON body for example" in it }, version) + } + assertTrue(discover(spec("\"3.0.3\"")).acquisition != DynamicDescriptorAcquisition.MetadataFallback) + } + + @Test + fun `a viewer catalog entry whose id is not a string does not name the app`() = runBlocking { + val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app.copy(id = "42"), execute = { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":42,"specs":["42"]}]""") + else -> response(404) + } + }) + assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition) + assertTrue(discovery.diagnostics.none { "could not load" in it || "invalid OpenAPI" in it }) + } + + private suspend fun discover(spec: String) = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> + when (request.relativePath) { + CATALOG -> response(200, """[{"id":"example","specs":["example"]}]""") + "$CATALOG/example" -> response(200, spec) + else -> response(404) + } + }) + + private fun spec(version: String) = """{"openapi":$version,"info":{"title":"Example","version":"1"},""" + + """"paths":{"/index.php/apps/example/api/items":{"get":{"operationId":"items-list","responses":{"200":""" + + """{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":""" + + """{"type":"object","properties":{"id":{"type":"integer"}}}}}}}}}}}}""" + + private companion object { + const val CATALOG = "/index.php/apps/ocs_api_viewer/apps" + + fun response(status: Int, body: String = ""): NextcloudApiResponse = + NextcloudApiResponse(status, body.encodeToByteArray(), "application/json", etag = null) + } +} From b29d84da992926919b2bbd1af333c807ec331126 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:18:42 +0000 Subject: [PATCH 46/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 249370553..04997295a 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -600,7 +600,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "d2080770aa8dc84e25b903980362b28cde54de11911b5970cfd482fe5b7f6687", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "8305d062e97dc452adf595fa2d5bc77ab1594dfe8cce47d7c47edf93d1d1b6bc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", @@ -863,7 +863,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/ChoresContractPolicy.kt": "8f11221917409f005c0a66503dd318a261af0f08377a8e2a18f93086561062ba", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicActionSemantics.kt": "3c9cf2bc9e76ca4d4ceeb418b0e033aae1ce550134757f439097af25c5711881", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt": "ec7c2e1587347bb493cc2b70715ffed4980c84ab814d9992023ea47ad8bad821", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt": "e970a773fda59ec561dfb17fe9f7cd3f010c2bbbcab993627dfab0caf355c787", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt": "dcc649a1a50e9663acdb9291cda87c515f21c8f51e477353b2d20aa181b9b262", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt": "08029fa1c8e28a0baa52710da39dd5d411ea57aedb57e97f8750ca84075a4ba5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt": "d475bcce470aec4971e58653e739c10ea68093585437e7921b9af739437ae2e2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt": "2750cbb63979cd1adf4264d5cb75d63d67980321f280e6c10ec64db35dcdfc0e", From 2d0936ac03e2bd08022d416bcc36652b59adc4e7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 19:07:07 +0200 Subject: [PATCH 47/49] fix(contracts): reserve the generation in the same step that registers an acquisition The publication fence now starts a generation under its lock in the same step that registers the in-flight owner, so a later refresh always gets a higher generation and an invalidation always fences an already registered acquisition. A caller that joins an acquisition in flight takes no generation. --- .../contracts/ContractPublicationFence.kt | 14 ++- .../SignedAppStoreContractAcquirer.kt | 17 ++- .../contracts/AcquisitionRegistrationTest.kt | 115 ++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionRegistrationTest.kt diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt index 98962feb9..ca1780ec0 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt @@ -6,7 +6,8 @@ package dev.obiente.nextcloudnative.contracts * * Each key keeps `started` (the newest generation handed out), `fenced` (results from this * generation or older are superseded), and `latest` (the newest accepted result). Both counters - * only grow. An acquisition takes generation `g = ++started` when it starts and ends in one row: + * only grow. An acquisition takes generation `g = ++started` in the same locked step that registers + * it as in flight ([startIfRegistered]); a caller joining it takes none. It ends in one row: * * | Event for generation g | g > fenced | g <= fenced (superseded) | * |------------------------------------------------|---------------------------------------------|------------------------------| @@ -41,7 +42,16 @@ internal class ContractPublicationFence { private val states = HashMap() /** Starts an acquisition for [request] and returns its generation. */ - fun start(request: ContractAcquisitionRequest): Long = synchronized(states) { + fun start(request: ContractAcquisitionRequest): Long = checkNotNull(startIfRegistered(request) { true }) + + /** + * Runs [register] under the fence lock and, when it registers a new acquisition, starts its + * generation in the same step. Returns null when [register] joined an existing acquisition, + * which takes no generation. No refresh or invalidation can observe a registered acquisition + * whose generation has not started yet. + */ + fun startIfRegistered(request: ContractAcquisitionRequest, register: () -> Boolean): Long? = synchronized(states) { + if (!register()) return null states.getOrPut(request, ::State).let { state -> ++state.started } } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt index 187c8b33d..654450735 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt @@ -38,6 +38,10 @@ class SignedAppStoreContractAcquirer( ConcurrentHashMap, CompletableFuture>() private val publicationFence = ContractPublicationFence() + /** Test seam: runs after an acquisition is registered and its generation started. */ + @Volatile + internal var afterRegistration: (ContractAcquisitionRequest, Long) -> Unit = { _, _ -> } + init { if (requireHttps) { require(rawGitHubBaseUrl.trimEnd('/') == OFFICIAL_RAW_GITHUB_BASE_URL) { @@ -62,10 +66,15 @@ class SignedAppStoreContractAcquirer( cachePolicy: AcquisitionCachePolicy = AcquisitionCachePolicy.UseCache, ): ContractAcquisitionOutcome { val owner = CompletableFuture() - val existing = inFlightAcquisitions.putIfAbsent(request to cachePolicy, owner) - if (existing != null) { + var existing: CompletableFuture? = null + // Registration and the generation are one step, so a later refresh or invalidation always sees it. + val generation = publicationFence.startIfRegistered(request) { + existing = inFlightAcquisitions.putIfAbsent(request to cachePolicy, owner) + existing == null + } + if (generation == null) { return try { - existing.get() + checkNotNull(existing).get() } catch (failure: ExecutionException) { throw failure.cause ?: failure } catch (failure: InterruptedException) { @@ -74,7 +83,7 @@ class SignedAppStoreContractAcquirer( } } return try { - val generation = publicationFence.start(request) + afterRegistration(request, generation) // Contracts are published through the fence; a no-contract ending is recorded here. when (val outcome = acquireUncoalesced(request, cachePolicy, generation)) { is ContractAcquisitionOutcome.Unavailable -> publicationFence.complete(request, generation, outcome) diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionRegistrationTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionRegistrationTest.kt new file mode 100644 index 000000000..4351083fe --- /dev/null +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/AcquisitionRegistrationTest.kt @@ -0,0 +1,115 @@ +package dev.obiente.nextcloudnative.contracts + +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient + +/** An acquisition's generation is reserved in the same step that registers it as in flight. */ +class AcquisitionRegistrationTest { + private val request = ContractAcquisitionRequest("cospend", "34.0.3", "4.0.2") + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val cache = MemoryVerifiedContractCache() + private val verifications = AtomicInteger() + private val registrations = Collections.synchronizedList(mutableListOf()) + private val firstRegistered = CountDownLatch(1) + private val releaseFirst = CountDownLatch(1) + + @AfterTest + fun shutDown() { + releaseFirst.countDown() + executor.shutdownNow() + } + + @Test + fun `joining an acquisition in flight takes no generation`() { + val fence = ContractPublicationFence() + assertEquals(null, fence.startIfRegistered(request) { false }) + assertEquals(1L, fence.startIfRegistered(request) { true }) + assertEquals(2L, fence.start(request)) + } + + @Test + fun `a refresh that starts after a registered acquisition gets the higher generation and wins`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val older = executor.submit { acquirer.acquireOutcome(request) } + assertTrue(firstRegistered.await(10, TimeUnit.SECONDS)) + + enqueueCatalogAndPackage(server) + assertEquals(document(1), document(acquirer.acquireOutcome(request, AcquisitionCachePolicy.Refresh))) + releaseFirst.countDown() + + assertEquals(document(1), document(older.get(10, TimeUnit.SECONDS))) + assertEquals(listOf(1L, 2L), registrations.toList()) + assertEquals(document(1), cache.load(request)?.document) + } + } + + @Test + fun `an invalidation after registration supersedes the registered acquisition`() { + MockWebServer().use { server -> + server.start() + val acquirer = acquirer(server) + val registered = executor.submit { acquirer.acquireOutcome(request) } + assertTrue(firstRegistered.await(10, TimeUnit.SECONDS)) + + acquirer.invalidateVerifiedContract(request) + enqueueCatalogAndPackage(server) + releaseFirst.countDown() + + // It verifies a contract, but may neither publish nor report it. + assertEquals( + ContractAcquisitionOutcome.Unavailable(ContractUnavailableReason.NoUsableContract), + registered.get(10, TimeUnit.SECONDS), + ) + assertEquals(1, verifications.get()) + assertNull(cache.load(request)) + } + } + + private fun document(version: Int) = "{\"version\":$version}" + + private fun document(outcome: ContractAcquisitionOutcome): String = + assertIs(outcome).contract.document + + private fun enqueueCatalogAndPackage(server: MockWebServer) { + val download = server.url("downloads/cospend-4.0.2.tar.gz") + server.enqueue(MockResponse(body = """[{"id":"cospend","certificate":"c","releases":[""" + + """{"version":"4.0.2","download":"$download","signature":"c2lnbmF0dXJl",""" + + """"signatureDigest":"sha512","isNightly":false}]}]""")) + server.enqueue(MockResponse(body = "package")) + } + + private fun acquirer(server: MockWebServer) = SignedAppStoreContractAcquirer( + httpClient = OkHttpClient(), + appStoreBaseUrl = server.url("api/v1").toString(), + requireHttps = false, + trustVerifier = object : AppPackageTrustVerifier { + override fun verifyAndExtract(release: AppStoreRelease, archive: ByteArray) = + VerifiedPackageContract(release.appId, release.version, "openapi.json", document(verifications.incrementAndGet())) + }, + verifiedContractCache = cache, + ).also { acquirer -> + // Pause the first acquisition right after it is registered, before it reads anything. + acquirer.afterRegistration = { _, generation -> + registrations += generation + if (registrations.size == 1) { + firstRegistered.countDown() + check(releaseFirst.await(10, TimeUnit.SECONDS)) { "The paused acquisition was never released." } + } + } + } +} From ac724534443696f159dc37c6041beab0c086558c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 17 Oct 2026 19:07:07 +0200 Subject: [PATCH 48/49] fix(ui): treat unidentified API Viewer catalog entries as inconclusive When the OCS API Viewer catalog does not list the app, an element that is not an object or has no string id may have been the app, so the probe records UnreadableResponse instead of absence, matching the App Store catalog rule. Such elements beside the app's entry are ignored, and a catalog made only of them is reported as unreadable. --- DYNAMIC_APP_DESCRIPTOR.md | 14 +++++-- .../app/DynamicSameOriginDiscovery.kt | 18 +++++++-- .../app/DynamicSameOriginValueTypeTest.kt | 39 ++++++++++++++++--- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/DYNAMIC_APP_DESCRIPTOR.md b/DYNAMIC_APP_DESCRIPTOR.md index 7b4190348..d722160e1 100644 --- a/DYNAMIC_APP_DESCRIPTOR.md +++ b/DYNAMIC_APP_DESCRIPTOR.md @@ -321,8 +321,10 @@ diagnostic records which of the two applied, with no path or exception text. Results are ordered per app, server version, and installed version by [`ContractPublicationFence.kt`](contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/ContractPublicationFence.kt). -Each acquisition takes a generation when it starts. A generation that has not -been superseded ends as follows: +Each acquisition takes a generation in the same step that registers it as in +flight, so a later refresh or invalidation always sees it as started; a caller +that joins an acquisition already in flight takes none. A generation that has +not been superseded ends as follows: | Ending | Effect on older generations | | --- | --- | @@ -363,13 +365,17 @@ wins among equals: JSON or declares no OpenAPI version (`UnreadableResponse`). An OCS API Viewer entry for the app whose spec list is missing, not an array, or has a malformed identifier with no usable one left is also `UnreadableResponse`; - usable identifiers beside malformed ones are still read. + usable identifiers beside malformed ones are still read. When the catalog + does not list the app, an element that is not an object or has no string + `id` may have been the app, as in App Store catalogs, so it is also + `UnreadableResponse`; such elements beside the app's entry are ignored. 4. `UnsupportedContract`: a JSON contract that declares a version other than OpenAPI 3, such as Swagger 2, or an OpenAPI 3 contract that cannot be compiled. A 404, another definitive status, a non-JSON response at a static contract -location, an empty or non-advertising OCS API Viewer catalog, or a well-formed +location, an empty OCS API Viewer catalog or one whose entries all name other +apps by a string `id`, or a well-formed spec list that is empty or names only other apps is absence and records nothing. Every App Store path that ends without a usable contract goes through one precedence point: no release can be selected because the server diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt index 179d8835c..5c227cd76 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt @@ -262,10 +262,20 @@ private suspend fun discoverOfficialViewerSpecIds( diagnostics += "OCS API Viewer returned an invalid app catalog${response.contentTypeDiagnostic()}." return emptyList() } - val appEntry = catalog - .mapNotNull { it as? JsonObject } - .firstOrNull { entry -> (entry["id"] as? JsonPrimitive)?.takeIf { it.isString }?.content == appId } + // An element that is not an object, or has no string `id`, may have been this app, as in App Store catalogs. + val identified = catalog.mapNotNull { element -> (element as? JsonObject)?.takeIf { it.viewerAppId() != null } } + val appEntry = identified.firstOrNull { entry -> entry.viewerAppId() == appId } if (appEntry == null) { + val unidentified = catalog.size - identified.size + if (unidentified > 0) { + probe.record(DynamicServerProbeFailure.UnreadableResponse) + diagnostics += if (unidentified == catalog.size) { + "OCS API Viewer returned an unreadable app catalog (stage: catalog, no readable app ID)." + } else { + "OCS API Viewer listed entries without a readable app ID, so $appId may be unlisted or unreadable." + } + return emptyList() + } diagnostics += "OCS API Viewer does not advertise an OpenAPI specification for $appId." return emptyList() } @@ -287,6 +297,8 @@ private suspend fun discoverOfficialViewerSpecIds( return specs } +private fun JsonObject.viewerAppId(): String? = (get("id") as? JsonPrimitive)?.takeIf { it.isString }?.content + private fun dynamicDiscoveryRequest(path: String): NextcloudApiRequest = NextcloudApiRequest( method = NextcloudApiMethod.GET, relativePath = path, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt index 7ea46c8d0..8123b6a83 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginValueTypeTest.kt @@ -20,16 +20,43 @@ class DynamicSameOriginValueTypeTest { } @Test - fun `a viewer catalog entry whose id is not a string does not name the app`() = runBlocking { - val discovery = discoverDynamicAppDescriptor("https://fixture.invalid", app.copy(id = "42"), execute = { request -> + fun `a viewer catalog element that may have been the app makes the server inconclusive`() = runBlocking { + val other = """{"id":"other","specs":["other"]}""" + for (unidentified in listOf("\"junk\"", "7", """{"id":42,"specs":["42"]}""", """{"specs":["example"]}""")) { + val discovery = viewer("[$other,$unidentified]") + assertEquals(DynamicContractFallbackReason.ServerProbeInconclusive, discovery.fallbackReason, unidentified) + assertTrue(discovery.diagnostics.any { "without a readable app ID" in it }, unidentified) + } + } + + @Test + fun `a viewer catalog made only of unidentified elements is unreadable`() = runBlocking { + val discovery = viewer("""["junk",{"id":42},null]""") + assertEquals(DynamicContractFallbackReason.ServerProbeInconclusive, discovery.fallbackReason) + assertTrue("OCS API Viewer returned an unreadable app catalog (stage: catalog, no readable app ID)." in discovery.diagnostics) + } + + @Test + fun `unidentified viewer elements beside the app entry are ignored`() = runBlocking { + val discovery = viewer("""["junk",{"id":42},{"id":"example","specs":["example"]}]""", spec("\"3.0.3\"")) + assertEquals(DynamicDescriptorAcquisition.OcsApiViewer, discovery.acquisition) + } + + @Test + fun `a well-formed viewer catalog without the app is absence`() = runBlocking { + val discovery = viewer("""[{"id":"other","specs":["other"]}]""") + assertEquals(null, discovery.fallbackReason) + assertTrue("OCS API Viewer does not advertise an OpenAPI specification for example." in discovery.diagnostics) + } + + private suspend fun viewer(catalog: String, spec: String? = null) = + discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> when (request.relativePath) { - CATALOG -> response(200, """[{"id":42,"specs":["42"]}]""") + CATALOG -> response(200, catalog) + "$CATALOG/example" -> spec?.let { response(200, it) } ?: response(404) else -> response(404) } }) - assertEquals(DynamicDescriptorAcquisition.MetadataFallback, discovery.acquisition) - assertTrue(discovery.diagnostics.none { "could not load" in it || "invalid OpenAPI" in it }) - } private suspend fun discover(spec: String) = discoverDynamicAppDescriptor("https://fixture.invalid", app, execute = { request -> when (request.relativePath) { From 39a5ec6db0ce3e67b5a4bda5f8ca2b932a64ff86 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:39:44 +0000 Subject: [PATCH 49/49] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 04997295a..6646ba655 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -600,7 +600,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationRestoration.kt": "8cc8a7afe3c67b1b4213814e04fae27f9bfd7d7453800e85fda99dbbe7865f1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicReadFailure.kt": "12b93861c3f9fc42627a0f4b9ad3ff4d0adc6a843d999b960c64a4bf14ed8cd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "8305d062e97dc452adf595fa2d5bc77ab1594dfe8cce47d7c47edf93d1d1b6bc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSameOriginDiscovery.kt": "ac27925250e04a313622e5c29519de602700cfe665bf9f9e10b5b22b43a06861", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicTableDetailLoading.kt": "2aadd6213df13b7a2c5cfcd5eb45ac9d7234930444ceeea141077c238c489e96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d",