P1: provider module — ShonarProvider contract, ServerUrl validation, LocalOnlyProvider

New package com.shonar.provider (docs/server-providers.md §1-2):
- ShonarProvider: single contract between app and any server (probe,
  connect/reconnect/disconnect w/ server-side revoke, deleteAccountAndData,
  upload/download/delete, sidecars, storageLocationSummary, authState flow).
  Documented contract rules: cancel-safe, idempotent per draft id,
  immutable originals, secret-free errors.
- ProviderTypes: ProviderDescriptor+Capability, sealed ProviderCredential
  (OAuthTokens / AppPassword / None — toString() redacts secrets),
  ProbeResult incl. TlsFailure(fingerprint) for explicit TOFU flow and
  ServicesFound for Start9/Umbrel platform probing, AuthState lifecycle,
  RemoteRef (opaque keys, never local paths), SidecarKind, StorageLocation,
  sealed ProviderError.
- ServerUrl.parse: https everywhere; http only for RFC1918/loopback/.local;
  rejects userinfo, traversal, non-http schemes, blanks. isPrivateHost with
  exact range tests (172.16-31 boundary covered).
- LocalOnlyProvider: first-class no-network provider (imports no HTTP lib),
  copy-based upload w/ progress, sidecar dir layout, sha256 etags, account
  deletion wipes root.
- ProviderRegistry: id->factory, active selection never touches local data;
  Nextcloud wins default once registered (P4).

Tests (47 Android unit tests green total, was 19):
- ProviderContractTest: shared suite every provider must pass — roundtrip
  byte-identity, monotonic progress ending 1.0, idempotent re-upload,
  delete removes audio+sidecars, sidecar overwrite, summary counts,
  leak-check (credential strings never appear in error messages).
- LocalOnlyProviderContractTest runs the contract + local specifics
  (probe=Incompatible, credentials rejected, deleteAccount wipes root).
- ServerUrlTest + ProviderRegistryTest: validation matrix and registry rules.

Verified on Pixel 8 Pro: APK installs, app launches, consent dialog
renders, 0 crash-log entries.
This commit is contained in:
avi 2026-09-08 18:38:21 -05:00
commit b4322b0697
7 changed files with 838 additions and 0 deletions

View file

@ -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> = _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<RemoteRecording> {
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) }
}
}
}

View file

@ -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<String, () -> ShonarProvider>) {
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
val activeId: StateFlow<String> = _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<ProviderDescriptor> =
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(...) }
),
)
}
}

View file

@ -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<Capability> = 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<String>) {
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<ServerUrl> {
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<DiscoveredService>) : 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<T>(val items: List<T>, 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)
}

View file

@ -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<AuthState>
/**
* 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<RemoteRecording>
// ---- 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
}

View file

@ -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())
}
}

View file

@ -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<Float>()
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<String> =
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<AuthState>) {
assertNotNull(flow.value)
}
}

View file

@ -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() }
}