sync: what adding and re-adding an account actually has to do

Re-authentication reported success for work it never did. The branch
took appPassword and dropped `found` and `selected` on the floor, so a
user whose account had 401'd walked the whole add flow, ticked
collections, and was shown Done — while no newly-ticked list was
created, and a moved principal or corrected username was discarded, so a
relocated account could never be repaired. It also never called
accounts.add, so an account deleted in Android Settings (nothing listens
for LOGIN_ACCOUNTS_CHANGED) stayed absent from Settings for ever while
syncing happily via WorkManager, and every later add answered
AlreadyExists. Unticked lists are still left attached, deliberately: the
picker pre-ticks what is *writable*, not what this account already
holds, so detaching would stop syncing a read-only share over a default
nobody chose.

Remove-then-re-add duplicated every list. remove() leaves them behind as
device-only lists on purpose and create() always inserted, so the user
got their old Personal full of tasks beside a freshly synced Personal
holding the same tasks from the server — the outcome the comment next to
it names as the one to avoid. A collection whose href is already on the
device is re-attached now, keeping its name, colour, ordering and tasks
and losing only a cursor that belonged to the account that is gone.
Rollback follows: it deletes what this attempt created and lets the FK
return a re-attached list to being device-only.

An account in the system with no Room row could not be removed from
inside the app — accounts.remove's result is ignored and find returns
null while the device is locked, so the pair can come apart, and every
later add then answered AlreadyExists with the accounts screen driven
off Room. That state is treated as an orphan to clean up.

create()'s tail is uncancellable, like remove()'s: a back gesture during
"Adding the account" left the row and its lists with no credential and
no system account, where the rollback never runs, needsSignIn is false
so re-auth will not fire, and every retry answers AlreadyExists.

remove() forgets the quarantine counts as well as the cadence cursors —
same keys, same globality, and a list re-attached to a new account would
otherwise inherit a resource that is skipped for ever.

And NeedsAuthentication's doc claimed the caller widens a credential
allowlist with its hosts. Nothing does, and nothing should without the
user's say-so; it says what the list is actually for.
This commit is contained in:
2026-09-09 11:32:12 +02:00
parent 00026e698b
commit b933705c84
6 changed files with 236 additions and 71 deletions
@@ -65,12 +65,18 @@ class CalDavDiscovery(
/**
* The server wants credentials. Not a failure — authenticate and retry.
*
* [hosts] names *which* hosts asked, which the caller needs in order to
* widen the credential allowlist. A cross-host home set (iCloud puts the
* principal on `caldav.icloud.com` and the home set on
* `pNN-caldav.icloud.com`) is otherwise unreachable: the interceptor
* withholds the credential from the second host, and without naming it
* here the caller can never learn what to allow.
* [hosts] names *which* hosts asked, so the caller can say which one it
* could not reach. A cross-host home set under the same registrable
* domain — iCloud puts the principal on `caldav.icloud.com` and the home
* set on `pNN-caldav.icloud.com` — is already covered, because that is
* the scope `CalDavHttp` gives the credential.
*
* ⚠️ What is *not* covered is a home set on a genuinely different
* registrable domain, which RFC 4791 §6.2.1 allows. Nothing widens the
* allowlist for it and nothing should without the user's say-so: the
* password would be offered to a host named by the first server, and one
* credential scope is what makes that decidable. The add flow reports
* such a host by name instead of pretending the account is empty.
*/
data class NeedsAuthentication(val hosts: List<String>) : Outcome
@@ -256,7 +256,14 @@ class NextcloudLoginFlow(
*/
internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl =
if (expected.isHttps && !actual.isHttps) {
actual.newBuilder().scheme("https").build()
// ⚠️ The port goes with the scheme. OkHttp only drops a *default*
// port across a scheme change, so `http://cloud.example.com:8080/`
// coerced to https keeps :8080 — a port that almost certainly speaks
// cleartext, and the promised coercion becomes a TLS handshake
// failure. The port that answered our poll is the one known to work,
// and a claimed port emitted alongside a wrong scheme comes from the
// same misconfiguration as the scheme did.
actual.newBuilder().scheme("https").port(expected.port).build()
} else {
actual
}
@@ -198,6 +198,21 @@ class NextcloudLoginFlowTest {
assertThat(coerced.host).isEqualTo("cloud.example.com")
}
@Test fun `a cleartext port does not survive the coercion`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val downgraded = "http://cloud.example.com:8080/".toHttpUrl()
// ⚠️ OkHttp only drops a *default* port across a scheme change, so
// :8080 would be carried into https — a port that almost certainly
// speaks cleartext, turning the promised coercion into a handshake
// failure. The port that answered the poll is the one known to work.
val coerced = flow.secureOrigin(expected, downgraded)
assertThat(coerced.scheme).isEqualTo("https")
assertThat(coerced.port).isEqualTo(443)
}
@Test fun `an already-secure server URL is left alone`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()