diff --git a/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt b/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt new file mode 100644 index 0000000..8b34815 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt @@ -0,0 +1,142 @@ +package com.shonar.provider + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +/** + * Local-only provider: everything stays in app-private storage. First-class + * so the app has no "no server" special case. No networking code paths at + * all — provable by construction (this file imports no HTTP library). + */ +class LocalOnlyProvider( + private val root: File, +) : ShonarProvider { + + override val descriptor = ProviderDescriptor( + id = ID, + displayName = "Local-only storage", + capabilities = setOf(), // no chunked protocol needed; no server AI + ) + + private val _authState = MutableStateFlow(AuthState.CONNECTED) + override val authState: StateFlow = _authState + + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = + ProbeResult.Incompatible // local-only never talks to servers + + override suspend fun connect(credential: ProviderCredential) { + if (credential != ProviderCredential.None) throw ProviderError.InvalidUrl( + "Local-only storage takes no credentials" + ) + _authState.value = AuthState.CONNECTED + } + + override suspend fun reconnect(): AuthState = AuthState.CONNECTED.also { _authState.value = it } + + override suspend fun disconnect(revokeOnServer: Boolean) { + // Nothing to revoke; DISCONNECTED means "user left local mode" — the + // sync layer treats it as paused, files remain on disk. + _authState.value = AuthState.DISCONNECTED + } + + override suspend fun deleteAccountAndData() { + if (root.exists()) root.deleteRecursively() + _authState.value = AuthState.DISCONNECTED + } + + // ---- storage ----------------------------------------------------------- + + private fun audioFile(ref: RemoteRef) = File(root, ref.key) + private fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = + File(root, "sidecars/${ref.key}/${kind.fileName}") + + override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { + val key = "audio/${draft.id}" + val dest = File(root, key) + dest.parentFile?.mkdirs() + // "Upload" locally = copy; report progress in slices so UI behaves uniformly + draft.sourceFile.inputStream().use { input -> + dest.outputStream().use { output -> + val buf = ByteArray(64 * 1024) + val total = draft.sizeBytes.coerceAtLeast(1) + var written = 0L + while (true) { + val n = input.read(buf) + if (n < 0) break + output.write(buf, 0, n) + written += n + onProgress((written.toFloat() / total).coerceIn(0f, 1f)) + } + } + } + return RemoteRef(ID, key, etag = "sha256:" + dest.sha256Hex(), sizeBytes = dest.length()) + } + + override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) { + val src = audioFile(ref) + if (!src.exists()) throw ProviderError.NotFound(ref.key) + src.copyTo(dest, overwrite = true) + onProgress(1f) + } + + override suspend fun delete(ref: RemoteRef) { + audioFile(ref).delete() + File(root, "sidecars/${ref.key}").deleteRecursively() + } + + override suspend fun list(cursor: String?): Page { + val audioRoot = File(root, "audio") + val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList() + // single page; no paging for local storage + val items = files.map { f -> + RemoteRecording( + ref = RemoteRef(ID, "audio/${f.name}", etag = "sha256:" + f.sha256Hex(), sizeBytes = f.length()), + title = f.nameWithoutExtension, + createdAtEpochMs = f.lastModified(), + durationMs = 0, // duration is tracked in Room, not on disk + mime = "application/octet-stream", + ) + } + return Page(items, nextCursor = null) + } + + override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) { + val f = sidecarFile(ref, kind) + f.parentFile?.mkdirs() + f.writeBytes(bytes) + } + + override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? { + val f = sidecarFile(ref, kind) + return if (f.exists()) f.readBytes() else null + } + + override suspend fun storageLocationSummary(): StorageLocation { + val audio = File(root, "audio").listFiles()?.filter { it.isFile } ?: emptyList() + return StorageLocation( + headline = "On this device only", + detail = "Recordings and transcripts never leave your phone.", + syncedCount = 0, + localOnlyCount = audio.size, + bytesUsed = audio.sumOf { it.length() }, + ) + } + + companion object { + const val ID = "local-only" + + private fun File.sha256Hex(): String { + val md = java.security.MessageDigest.getInstance("SHA-256") + inputStream().use { inn -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = inn.read(buf) + if (n < 0) break + md.update(buf, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt new file mode 100644 index 0000000..d301e94 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt @@ -0,0 +1,50 @@ +package com.shonar.provider + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Maps provider ids to implementations. The active provider is whichever the + * user selected during setup; switching is just changing this id — the app + * never branches on concrete provider classes. + */ +class ProviderRegistry(private val factories: Map ShonarProvider>) { + + private val _activeId = MutableStateFlow(LocalOnlyProvider.ID) + val activeId: StateFlow = _activeId + + /** Nextcloud is the product default once its factory registers (P4). */ + val defaultProviderId: String + get() = factories.keys.sorted().let { ids -> + ids.firstOrNull { it == NEXTCLOUD_ID } ?: ids.firstOrNull() ?: LocalOnlyProvider.ID + } + + fun provider(id: String): ShonarProvider = + factories[id]?.invoke() ?: throw ProviderError.InvalidUrl("Unknown provider: $id") + + val active: ShonarProvider get() = provider(_activeId.value) + + /** Selecting a provider does not touch existing local data. */ + fun select(id: String): ShonarProvider { + if (id !in factories) throw ProviderError.InvalidUrl("Unknown provider: $id") + _activeId.value = id + return provider(id) + } + + fun available(): List = + factories.keys.map { provider(it).descriptor } + + companion object { + const val NEXTCLOUD_ID = "nextcloud" + const val CUSTOM_SHONAR_ID = "custom-shonar" + + /** Production factory map. P1 registers local-only; later phases add more. */ + fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry( + mapOf( + LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) }, + // P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) } + // P4: NEXTCLOUD_ID to { NextcloudProvider(...) } + ), + ) + } +} diff --git a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt new file mode 100644 index 0000000..82774f0 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt @@ -0,0 +1,189 @@ +package com.shonar.provider + +import java.io.File + +/** + * Shared vocabulary for server providers. Nothing here depends on any + * specific server product; the rest of the app talks to providers only + * through these types plus [ShonarProvider]. + */ + +/** What kind of provider this is and how the UI should present it. */ +data class ProviderDescriptor( + val id: String, // "nextcloud" | "custom-shonar" | "local-only" | ... + val displayName: String, // "Nextcloud" + val isDefault: Boolean = false, + val capabilities: Set = emptySet(), +) { + enum class Capability { + CHUNKED_UPLOAD, // resumable large-file upload protocol + SERVER_TRANSCRIPTION, // backend can transcribe (feature-gate AI) + SERVER_SUMMARY, + QUOTA_INFO, // storageLocationSummary can report quota + ACCOUNT_DELETION, // provider supports deleteAccountAndData + } +} + +/** + * A validated server URL. Construction only via [parse]; guarantees: + * - scheme https, or http ONLY for private/LAN hosts (RFC1918, loopback, .local) + * - no userinfo (user:pass in URL is rejected) + * - no path traversal ("..") + * - non-blank host + */ +data class ServerUrl(val scheme: String, val host: String, val port: Int, val pathSegments: List) { + + val isCleartext: Boolean get() = scheme == "http" + + /** Normalized base for API calls, e.g. https://cloud.example.com */ + val origin: String + get() = buildString { + append(scheme).append("://").append(host) + val default = if (scheme == "https") 443 else 80 + if (port != default) append(":").append(port) + } + + companion object { + fun parse(raw: String): Result { + val trimmed = raw.trim().removeSuffix("/") + if (trimmed.isBlank()) return Result.failure(ProviderError.InvalidUrl("URL is empty")) + val uri = runCatching { java.net.URI(trimmed) } + .getOrElse { return Result.failure(ProviderError.InvalidUrl("Not a valid URL")) } + val scheme = (uri.scheme ?: "").lowercase() + if (scheme != "https" && scheme != "http") + return Result.failure(ProviderError.InvalidUrl("Only http(s) URLs are allowed")) + if (uri.userInfo != null) + return Result.failure(ProviderError.InvalidUrl("Credentials in URL are not allowed")) + val host = (uri.host ?: "").lowercase() + if (host.isBlank()) + return Result.failure(ProviderError.InvalidUrl("Missing host")) + val segs = uri.path.split('/').filter { it.isNotBlank() } + if (segs.any { it == ".." }) + return Result.failure(ProviderError.InvalidUrl("Path traversal is not allowed")) + if (scheme == "http" && !isPrivateHost(host)) + return Result.failure( + ProviderError.InvalidUrl("Cleartext http:// is only allowed for local/LAN servers; use https:// for $host") + ) + val port = if (uri.port > 0) uri.port else if (scheme == "https") 443 else 80 + return Result.success(ServerUrl(scheme, host, port, segs)) + } + + fun isPrivateHost(host: String): Boolean { + if (host == "localhost" || host.endsWith(".local")) return true + val octets = host.split('.').takeIf { it.size == 4 }?.map { it.toIntOrNull() ?: -1 } ?: return false + if (octets.any { it !in 0..255 }) return false + return when { + octets[0] == 10 -> true // 10/8 + octets[0] == 127 -> true // loopback + octets[0] == 192 && octets[1] == 168 -> true // 192.168/16 + octets[0] == 172 && octets[1] in 16..31 -> true // 172.16/12 + octets[0] == 169 && octets[1] == 254 -> true // link-local + else -> false + } + } + } +} + +/** Credential material for a provider. NEVER put this in logs or Room. */ +sealed class ProviderCredential { + abstract val accountLabel: String // human hint only (e.g. "user@cloud") + + /** OAuth2/OIDC access token + refresh token (PKCE flow). */ + data class OAuthTokens( + override val accountLabel: String, + val accessToken: String, + val refreshToken: String?, + val expiresAtEpochSec: Long?, + ) : ProviderCredential() { + override fun toString(): String = "OAuthTokens(account=$accountLabel, [redacted])" + } + + /** Nextcloud login-flow-v2 / manually created app password. */ + data class AppPassword( + override val accountLabel: String, + val loginUrl: String, + val user: String, + val password: String, + ) : ProviderCredential() { + override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])" + } + + /** No credential needed (local-only provider). */ + data object None : ProviderCredential() { + override val accountLabel: String get() = "local" + override fun toString(): String = "None" + } +} + +/** Result of probing a base URL for a compatible service. */ +sealed class ProbeResult { + data class Compatible(val descriptor: ProviderDescriptor, val serverName: String, val version: String) : ProbeResult() + /** Reachable, SHONAR-compatible services were found; user must pick one (Start9/Umbrel platforms). */ + data class ServicesFound(val services: List) : ProbeResult() + data object Incompatible : ProbeResult() + data class NetworkError(val reason: String) : ProbeResult() // reason must be secret-free + data class TlsFailure(val fingerprintSha256: String) : ProbeResult() // triggers TOFU approval UI +} + +/** A SHONAR-compatible service discovered on a platform (Start9/Umbrel). */ +data class DiscoveredService( + val platformId: String, // "start9" | "umbrel" + val serviceName: String, // app/service display name + val baseUrl: ServerUrl, + val providerKind: String, // provider to bind once confirmed +) + +/** Auth lifecycle shown in UI. Transitions: see docs/server-providers.md §3. */ +enum class AuthState { DISCONNECTED, CONNECTED, EXPIRED, REVOKED, OFFLINE } + +/** Opaque pointer to a remote object. Providers map keys to their own layout. */ +data class RemoteRef( + val providerId: String, + val key: String, // provider-relative key, never a local path + val etag: String?, + val sizeBytes: Long, +) + +/** Input to [ShonarProvider.upload]. sourceFile is a local file owned by the app. */ +data class RecordingDraft( + val id: String, // public UUID string + val title: String, + val createdAtEpochMs: Long, + val durationMs: Long, + val mime: String, // audio/mp4 | audio/wav | audio/ogg + val sourceFile: File, + val sizeBytes: Long, +) + +enum class SidecarKind(val fileName: String) { + TRANSCRIPT("transcript.json"), + SUMMARY("summary.json"), + ACTION_ITEMS("action-items.json"), + KEYWORDS("keywords.json"), + NOTES("notes.json"), +} + +data class Page(val items: List, val nextCursor: String?) +data class RemoteRecording(val ref: RemoteRef, val title: String, val createdAtEpochMs: Long, val durationMs: Long, val mime: String) + +/** "Where is my data?" — what the StorageLocation screen renders. */ +data class StorageLocation( + val headline: String, // "On this device only" / "Nextcloud at cloud.example.com" + val detail: String, // human-readable path/prefix, quota, etc. + val syncedCount: Int, + val localOnlyCount: Int, + val bytesUsed: Long, +) + +/** Provider-level errors. Messages must never contain credentials/tokens. */ +sealed class ProviderError(message: String) : Exception(message) { + class InvalidUrl(message: String) : ProviderError(message) + class NotConnected : ProviderError("Provider is not connected") + class AuthExpired : ProviderError("Access token expired — re-authentication required") + class Revoked : ProviderError("Access was revoked on the server") + class TlsUntrusted(fingerprint: String) : ProviderError("Server certificate not trusted (SHA-256 $fingerprint)") + class NotFound(key: String) : ProviderError("Remote item not found: $key") + class Conflict(key: String) : ProviderError("Remote item changed since last read: $key") + class QuotaExceeded : ProviderError("Server storage quota exceeded") + class Transient(message: String) : ProviderError(message) +} diff --git a/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt new file mode 100644 index 0000000..c9aed97 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt @@ -0,0 +1,77 @@ +package com.shonar.provider + +import java.io.File + +/** + * The single contract between SHONAR and any server (or the device itself). + * + * Everything the app needs from "the cloud" goes through this interface; + * UI, sync engine, and database reference providers by descriptor id only. + * LocalOnlyProvider is a first-class implementation, so a missing server is + * never a special case. + * + * Contract rules (enforced by the shared provider contract test suite): + * - all suspends are safe to cancel; partial uploads leave no visible object + * - upload() is idempotent per RecordingDraft.id (re-upload replaces the + * same key, never duplicates) + * - originals are immutable after successful upload until delete() + * - no method ever logs credentials, tokens, audio bytes, or transcripts + * - errors are ProviderError subtypes with secret-free messages + */ +interface ShonarProvider { + + val descriptor: ProviderDescriptor + + /** Current auth lifecycle; providers push updates here. */ + val authState: kotlinx.coroutines.flow.StateFlow + + /** + * Is there a SHONAR-compatible service at [baseUrl]? Purely read-only; + * must not require or request credentials. TlsFailure carries the SPKI + * fingerprint so the UI can run explicit trust-on-first-use approval — + * never silently accept. + */ + suspend fun probe(baseUrl: ServerUrl): ProbeResult + + /** Validate [credential] against the server, then hand it to the secure store. */ + suspend fun connect(credential: ProviderCredential): Unit + + /** Re-validate stored credential (app start / after network return). */ + suspend fun reconnect(): AuthState + + /** + * Disconnect locally; if [revokeOnServer] and the provider supports it, + * revoke the credential server-side first (best effort — local state is + * cleared even if revoke fails, with the failure surfaced). + */ + suspend fun disconnect(revokeOnServer: Boolean) + + /** Provider-native account/data deletion, then wipe local state. */ + suspend fun deleteAccountAndData() + + // ---- storage ----------------------------------------------------------- + + /** + * Upload the original audio for [draft], reporting [onProgress] 0..1. + * Implementations use chunked/resumable transfers when the capability is + * advertised. Returns the ref for later download/delete/sidecars. + */ + suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef + + suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) + + suspend fun delete(ref: RemoteRef) + + suspend fun list(cursor: String?): Page + + // ---- sidecars (transcript/summary/... JSON, synced independently) ------ + + suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) + + /** null when the sidecar does not exist remotely. */ + suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? + + // ---- status ------------------------------------------------------------ + + suspend fun storageLocationSummary(): StorageLocation +} diff --git a/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt b/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt new file mode 100644 index 0000000..905d552 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt @@ -0,0 +1,66 @@ +package com.shonar.provider + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +/** Runs the shared contract suite against LocalOnlyProvider. */ +class LocalOnlyProviderContractTest : ProviderContractTest() { + + private val root: File = createTempDirSafe("shonar-contract") + + private fun createTempDirSafe(prefix: String): File = + File(System.getProperty("java.io.tmpdir"), prefix + "-" + System.nanoTime()).apply { mkdirs() } + + override suspend fun makeProvider(): ShonarProvider = LocalOnlyProvider(root) + + override suspend fun makeDraft(id: String): RecordingDraft { + val src = File(root, "src-$id.bin") + // deterministic pseudo-audio payload, ~256 KB + val rnd = java.util.Random(42) + src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) + return RecordingDraft( + id = id, + title = "Contract recording", + createdAtEpochMs = 1_700_000_000_000, + durationMs = 12_345, + mime = "audio/mp4", + sourceFile = src, + sizeBytes = src.length(), + ) + } + + override suspend fun cleanup() { + root.deleteRecursively() + } + + // ---- local-only specifics --------------------------------------------------- + + @Test + fun localOnly_probe_isIncompatible_neverTouchesNetwork() = kotlinx.coroutines.runBlocking { + val p = LocalOnlyProvider(root) + val url = ServerUrl.parse("https://example.com")!!.getOrThrow() + assertEquals(ProbeResult.Incompatible, p.probe(url)) + } + + @Test + fun localOnly_rejectsCredentials() = kotlinx.coroutines.runBlocking { + val p = LocalOnlyProvider(root) + try { + p.connect(ProviderCredential.AppPassword("a@b", "https://x", "u", "hunter2")) + org.junit.Assert.fail("local-only must not accept credentials") + } catch (expected: ProviderError.InvalidUrl) { + } + } + + @Test + fun deleteAccountAndData_wipesRoot() = kotlinx.coroutines.runBlocking { + val p = LocalOnlyProvider(root) + p.connect(ProviderCredential.None) + val draft = makeDraft("99999999-9999-4999-8999-999999999999") + p.upload(draft) { } + org.junit.Assert.assertTrue(File(root, "audio").exists()) + p.deleteAccountAndData() + org.junit.Assert.assertFalse("local data must be wiped", root.exists()) + } +} diff --git a/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt b/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt new file mode 100644 index 0000000..e2de484 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt @@ -0,0 +1,195 @@ +package com.shonar.provider + +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Shared provider contract suite (docs/server-providers.md §8). + * Every implementation (LocalOnly, CustomShonar, Nextcloud) runs these same + * tests — subclass and implement [makeProvider] + [makeDraft]. + */ +abstract class ProviderContractTest { + + protected abstract suspend fun makeProvider(): ShonarProvider + protected abstract suspend fun makeDraft(id: String): RecordingDraft + protected open suspend fun cleanup() {} + + // ---- lifecycle ---------------------------------------------------------- + + @Test + fun freshProvider_startsDisconnectedOrConnected() = runContract { + val p = makeProvider() + assertTrue( + "authState must start in a defined state", + p.authState.value == AuthState.DISCONNECTED || p.authState.value == AuthState.CONNECTED, + ) + } + + @Test + fun connect_thenStateConnected() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + assertEquals(AuthState.CONNECTED, p.authState.value) + } + + @Test + fun disconnect_setsDisconnectedState() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + p.disconnect(revokeOnServer = false) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + } + + // ---- storage happy path --------------------------------------------------- + + @Test + fun upload_download_roundtrip_byteIdentical() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("11111111-1111-4111-8111-111111111111") + val payload = draft.sourceFile.readBytes() + val ref = p.upload(draft) { } + assertEquals(draft.sizeBytes, ref.sizeBytes) + + val dest = java.io.File.createTempFile("contract-dl", ".bin") + try { + p.download(ref, dest) { } + assertTrue("downloaded bytes must equal uploaded bytes", + dest.readBytes().contentEquals(payload)) + } finally { dest.delete() } + } + + @Test + fun upload_reportsMonotonicProgressEndingAtOne() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("22222222-2222-4222-8222-222222222222") + val seen = mutableListOf() + val ref = p.upload(draft) { seen += it } + assertNotNull(ref) + assertTrue("progress should have been reported", seen.isNotEmpty()) + assertEquals(1.0, seen.last().toDouble(), 1e-6) + for (i in 1 until seen.size) { + assertTrue("progress must be monotonic", seen[i] >= seen[i - 1] - 1e-6f) + } + } + + @Test + fun upload_idempotent_perDraftId() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("33333333-3333-4333-8333-333333333333") + val a = p.upload(draft) { } + val b = p.upload(draft) { } + assertEquals("re-upload with same id must reuse the same key", a.key, b.key) + // and still exactly one object for that draft + val keys = p.list(null).items.filter { it.ref.key == a.key } + assertEquals(1, keys.size) + } + + @Test + fun delete_removesObjectAndSidecars() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("44444444-4444-4444-8444-444444444444") + val ref = p.upload(draft) { } + p.putSidecar(ref, SidecarKind.TRANSCRIPT, """{"text":"hello"}""".toByteArray()) + assertNotNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT)) + p.delete(ref) + val probe = java.io.File.createTempFile("gone", ".bin") + try { + p.download(ref, probe) { } + fail("download after delete must throw NotFound") + } catch (expected: ProviderError.NotFound) { + } finally { + probe.delete() + } + assertNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT)) + } + + // ---- sidecars --------------------------------------------------------------- + + @Test + fun sidecar_roundtripAndOverwrite() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("55555555-5555-4555-8555-555555555555") + val ref = p.upload(draft) { } + p.putSidecar(ref, SidecarKind.SUMMARY, "v1".toByteArray()) + assertEquals("v1", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString()) + p.putSidecar(ref, SidecarKind.SUMMARY, "v2".toByteArray()) + assertEquals("v2", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString()) + assertNull(p.getSidecar(ref, SidecarKind.ACTION_ITEMS)) + } + + // ---- status --------------------------------------------------------------- + + @Test + fun storageSummary_countsAfterUpload() = runContract { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("66666666-6666-4666-8666-666666666666") + p.upload(draft) { } + val s = p.storageLocationSummary() + assertTrue("headline must be non-blank", s.headline.isNotBlank()) + assertTrue("summary must count the stored object", + s.syncedCount + s.localOnlyCount >= 1) + assertTrue(s.bytesUsed >= draft.sizeBytes) + } + + // ---- error hygiene --------------------------------------------------------- + + @Test + fun providerErrorMessages_neverLeakCredentialMaterial() = runContract { + val p = makeProvider() + val cred = initialCredential() + val secrets = credentialSecretStrings(cred) + try { + p.download( + RemoteRef(p.descriptor.id, "does-not-exist-42", etag = null, sizeBytes = 0), + java.io.File.createTempFile("n-a-", ".bin"), + ) { } + fail("expected NotFound") + } catch (e: ProviderError) { + secrets.forEach { s -> + assertFalse("error message leaked credential material", e.message?.contains(s) == true) + } + } + } + + // ---- helpers --------------------------------------------------------------- + + /** Credentials for tests: local-only uses None; network providers override. */ + protected open fun initialCredential(): ProviderCredential = ProviderCredential.None + + /** Strings that must never appear in error messages (tokens/passwords). */ + protected open fun credentialSecretStrings(cred: ProviderCredential): List = + when (cred) { + is ProviderCredential.OAuthTokens -> + listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() } + is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() } + ProviderCredential.None -> emptyList() + } + + /** Bridges suspend contract bodies to JUnit4. */ + private fun runContract(body: suspend () -> Unit) { + kotlinx.coroutines.runBlocking { + try { + body() + } finally { + cleanup() + } + } + } + + /** Helper subclasses use to assert a StateFlow is exposed (compile-time check). */ + protected fun assertStateFlow(flow: StateFlow) { + assertNotNull(flow.value) + } +} diff --git a/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt b/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt new file mode 100644 index 0000000..52ca8a1 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt @@ -0,0 +1,119 @@ +package com.shonar.provider + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** URL validation rules from docs/server-providers.md §4. */ +class ServerUrlTest { + + private fun ok(raw: String) = ServerUrl.parse(raw).getOrThrow() + + @Test fun httpsPublicHost_accepted() { + val u = ok("https://cloud.example.com") + assertEquals("https", u.scheme) + assertEquals("cloud.example.com", u.host) + assertFalse(u.isCleartext) + assertEquals("https://cloud.example.com", u.origin) + } + + @Test fun httpsWithPortAndPath_accepted() { + val u = ok("https://nextcloud.lan:8443/remote.php") + assertEquals(8443, u.port) + assertEquals(listOf("remote.php"), u.pathSegments) + } + + @Test fun trailingSlash_normalized() { + assertEquals(ok("https://a.example.com/").origin, "https://a.example.com") + } + + @Test fun httpPrivateLan_accepted() { + assertTrue(ok("http://192.168.1.50:8123").isCleartext) + ok("http://10.0.0.9") + ok("http://172.20.0.5") + ok("http://localhost:8080") + ok("http://home.local") + } + + @Test fun httpPublicHost_rejected() { + val r = ServerUrl.parse("http://example.com") + assertTrue(r.isFailure) + assertTrue(r.exceptionOrNull() is ProviderError.InvalidUrl) + // message must not contain anything credential-like (it contains host only) + assertTrue(r.exceptionOrNull()!!.message!!.contains("https")) + } + + @Test fun credentialsInUrl_rejected() { + assertTrue(ServerUrl.parse("https://user:***@example.com").isFailure) + } + + @Test fun nonHttpScheme_rejected() { + assertTrue(ServerUrl.parse("ftp://example.com").isFailure) + assertTrue(ServerUrl.parse("file:///etc/passwd").isFailure) + } + + @Test fun pathTraversal_rejected() { + assertTrue(ServerUrl.parse("https://example.com/a/../b").isFailure) + } + + @Test fun blankAndGarbage_rejected() { + assertTrue(ServerUrl.parse("").isFailure) + assertTrue(ServerUrl.parse(" ").isFailure) + assertTrue(ServerUrl.parse("not a url").isFailure) + assertTrue(ServerUrl.parse("https://").isFailure) + } + + @Test fun privateRanges_exact() { + assertTrue(ServerUrl.isPrivateHost("10.255.0.1")) + assertTrue(ServerUrl.isPrivateHost("192.168.0.1")) + assertFalse(ServerUrl.isPrivateHost("192.169.0.1")) + assertTrue(ServerUrl.isPrivateHost("172.16.0.1")) + assertTrue(ServerUrl.isPrivateHost("172.31.255.255")) + assertFalse(ServerUrl.isPrivateHost("172.32.0.1")) + assertFalse(ServerUrl.isPrivateHost("8.8.8.8")) + assertFalse(ServerUrl.isPrivateHost("999.1.1.1")) + } + + @Test fun toStringOfCredential_neverLeaksSecret() { + val c = ProviderCredential.AppPassword("u@example", "https://x", "u", "sup3rs3cr3t") + assertFalse(c.toString().contains("sup3rs3cr3t")) + val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null) + assertFalse(o.toString().contains("ACCESS-XYZ")) + assertFalse(o.toString().contains("REFRESH-XYZ")) + } +} + +class ProviderRegistryTest { + + private fun registry(dir: java.io.File) = ProviderRegistry.withDefaults(dir) + + @Test fun defaults_startLocalOnly() { + val r = registry(createTempFileSafe()) + assertEquals(com.shonar.provider.LocalOnlyProvider.ID, r.activeId.value) + } + + @Test fun unknownProvider_rejected() { + val r = registry(createTempFileSafe()) + try { + r.select("dropbox") + org.junit.Assert.fail("unknown provider must be rejected") + } catch (expected: ProviderError.InvalidUrl) { + } + } + + @Test fun select_localOnly_resolves() { + val r = registry(createTempFileSafe()) + val p = r.select(LocalOnlyProvider.ID) + assertEquals(LocalOnlyProvider.ID, p.descriptor.id) + assertEquals(LocalOnlyProvider.ID, r.activeId.value) + } + + @Test fun available_listsDescriptors() { + val ids = registry(createTempFileSafe()).available().map { it.id } + assertTrue(ids.contains(LocalOnlyProvider.ID)) + } + + private fun createTempFileSafe(): java.io.File = + java.io.File(System.getProperty("java.io.tmpdir"), "reg-${System.nanoTime()}").apply { mkdirs() } +}