Compare commits

...

3 Commits

Author SHA1 Message Date
Andre Rosado
d83720f369 Parsing data from shadow dom selectors 2026-05-27 13:09:25 +01:00
Andre Rosado
5fde28716a Simplified sync on fill assist to match the vault sync
Updating last fetch timestamp when schema major does not match
2026-05-26 13:30:56 +01:00
Andre Rosado
7fcae79136 Fetches, parses, and caches map-the-web data 2026-05-25 18:48:30 +01:00
33 changed files with 2157 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.app.Application
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
@@ -20,6 +21,9 @@ import javax.inject.Inject
class BitwardenApplication : Application() {
// Inject classes here that must be triggered on startup but are not otherwise consumed by
// other callers.
@Inject
lateinit var fillAssistManager: FillAssistManager
@Inject
lateinit var logsManager: LogsManager

View File

@@ -0,0 +1,44 @@
package com.x8bit.bitwarden.data.autofill.datasource.disk
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
/**
* Disk source for persisting fill-assist targeting rules per server.
*
* All operations are scoped by [serverUrl] (the fill-assist CDN base URL provided by the server
* config), so multiple accounts on the same server share one cached copy of the rules while
* accounts on different servers remain independent.
*/
interface FillAssistDiskSource {
/**
* Returns the cached [FillAssistRules] for [serverUrl], or null if none are stored.
*/
fun getFillAssistRules(serverUrl: String): FillAssistRules?
/**
* Stores [rules] for [serverUrl], or removes the entry when [rules] is null.
*/
fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?)
/**
* Returns the last known content hash (CID) for [serverUrl], or null if none is stored.
*/
fun getLastKnownCid(serverUrl: String): String?
/**
* Stores [cid] for [serverUrl], or removes the entry when [cid] is null.
*/
fun storeLastKnownCid(serverUrl: String, cid: String?)
/**
* Returns the epoch-millisecond timestamp of the last successful fetch for [serverUrl],
* or null if never fetched.
*/
fun getLastFetchTimestamp(serverUrl: String): Long?
/**
* Stores [timestamp] for [serverUrl], or removes the entry when [timestamp] is null.
*/
fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?)
}

View File

@@ -0,0 +1,72 @@
package com.x8bit.bitwarden.data.autofill.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseDiskSource
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
// Bump this constant in two cases:
// 1. The parsing logic changes in a way that invalidates previously cached results.
// 2. EXPECTED_SCHEMA_MAJOR in FillAssistManagerImpl is updated to support a new schema major.
// Without bumping this, the staleness check would skip re-downloading data that was previously
// rejected for an unsupported schema — meaning the app would never pick up the new rules.
// On the next app launch after a bump, all stored fill-assist data is cleared and re-downloaded.
private const val CURRENT_CACHE_VERSION = 0
private const val FILL_ASSIST_CACHE_VERSION_KEY = "fillAssistCacheVersion"
private const val FILL_ASSIST_RULES_KEY = "fillAssistRules"
private const val FILL_ASSIST_CID_KEY = "fillAssistLastCid"
private const val FILL_ASSIST_TIMESTAMP_KEY = "fillAssistLastFetchTimestamp"
/**
* Primary implementation of [FillAssistDiskSource].
*/
class FillAssistDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseDiskSource(sharedPreferences),
FillAssistDiskSource {
init {
performMigrationIfNeeded()
}
override fun getFillAssistRules(serverUrl: String): FillAssistRules? =
getString(FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl))
?.let { json.decodeFromStringOrNull(it) }
override fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) {
putString(
FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl),
rules?.let { json.encodeToString(it) },
)
}
override fun getLastKnownCid(serverUrl: String): String? =
getString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl))
override fun storeLastKnownCid(serverUrl: String, cid: String?) {
putString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl), cid)
}
override fun getLastFetchTimestamp(serverUrl: String): Long? =
getLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl))
override fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) {
putLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl), timestamp)
}
private fun performMigrationIfNeeded() {
if (getInt(FILL_ASSIST_CACHE_VERSION_KEY) == CURRENT_CACHE_VERSION) return
clearAllData()
}
private fun clearAllData() {
removeWithPrefix(FILL_ASSIST_RULES_KEY)
removeWithPrefix(FILL_ASSIST_CID_KEY)
removeWithPrefix(FILL_ASSIST_TIMESTAMP_KEY)
putInt(FILL_ASSIST_CACHE_VERSION_KEY, CURRENT_CACHE_VERSION)
}
}

View File

@@ -0,0 +1,57 @@
package com.x8bit.bitwarden.data.autofill.di
import android.content.SharedPreferences
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.service.FillAssistService
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSourceImpl
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import kotlinx.serialization.json.Json
import javax.inject.Singleton
/**
* Provides fill-assist dependencies.
*/
@Module
@InstallIn(SingletonComponent::class)
object FillAssistModule {
@Provides
@Singleton
fun providesFillAssistDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): FillAssistDiskSource =
FillAssistDiskSourceImpl(
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun providesFillAssistManager(
fillAssistService: FillAssistService,
fillAssistDiskSource: FillAssistDiskSource,
featureFlagManager: FeatureFlagManager,
serverConfigRepository: ServerConfigRepository,
clock: Clock,
dispatcherManager: DispatcherManager,
): FillAssistManager =
FillAssistManagerImpl(
fillAssistService = fillAssistService,
fillAssistDiskSource = fillAssistDiskSource,
featureFlagManager = featureFlagManager,
serverConfigRepository = serverConfigRepository,
clock = clock,
dispatcherManager = dispatcherManager,
)
}

View File

@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
/**
* Manages fetching and caching fill-assist targeting rules.
*
* Rules are scoped per server (the fill-assist CDN URL from server config), so multiple accounts
* on the same server share one cached copy.
*/
interface FillAssistManager {
/**
* Triggers a background sync if no sync is currently running. The sync fetches and persists
* fill-assist rules when the feature flag is enabled and cached data is stale.
*/
fun syncIfNecessary()
/**
* Returns the last successfully cached [FillAssistRules] for the active server, or null if
* none exist.
*/
fun getFillAssistRules(): FillAssistRules?
}

View File

@@ -0,0 +1,233 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import java.time.Clock
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.service.FillAssistService
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules.SelectorClause
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import timber.log.Timber
private const val CURRENT_FORMS_VERSION = "v0"
private const val EXPECTED_SCHEMA_MAJOR = "0"
/** Re-fetch interval in milliseconds (6 hours, matching the browser implementation). */
private const val UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000L
// Matches [attr='value'] and [attr="value"] attribute selectors.
private val ATTRIBUTE_REGEX = Regex("""\[([a-zA-Z\-]+)=['"](.*?)['"]]""")
// Matches the CSS #id shorthand (e.g. "input#oid", "select#state").
// Used as a fallback when [id='value'] is absent.
private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""")
// Extracts the leading tag name from a selector (e.g. "input", "select", "form").
private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""")
/**
* Primary implementation of [FillAssistManager].
*/
@Suppress("LongParameterList")
class FillAssistManagerImpl(
private val fillAssistService: FillAssistService,
private val fillAssistDiskSource: FillAssistDiskSource,
private val featureFlagManager: FeatureFlagManager,
private val serverConfigRepository: ServerConfigRepository,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : FillAssistManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
private var syncJob: Job = Job().apply { complete() }
init {
serverConfigRepository.serverConfigStateFlow
.filterNotNull()
.onEach { syncIfNecessary() }
.launchIn(unconfinedScope)
}
override fun syncIfNecessary() {
if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return
val serverUrl = serverConfigRepository.serverConfigStateFlow.value
?.serverData?.environment?.fillAssistRulesUrl ?: return
val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L
if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return
if (!syncJob.isCompleted) return
syncJob = ioScope.launch { sync(serverUrl) }
}
private suspend fun sync(serverUrl: String) = runCatching {
// Always fetch the manifest — it is the CID staleness check.
val manifest = fillAssistService
.getManifest(url = serverUrl.trimEnd('/') + "/manifest.json")
.getOrThrow()
val versionEntry = manifest.maps?.forms?.get(CURRENT_FORMS_VERSION)
?: error("Version $CURRENT_FORMS_VERSION not found in manifest")
val cid = versionEntry.cid
?: error("No CID for version $CURRENT_FORMS_VERSION in manifest")
if (versionEntry.deprecated == true) {
Timber.w("Fill-assist forms $CURRENT_FORMS_VERSION is deprecated")
}
// CID check: data on the server is unchanged — update the timestamp and skip download.
if (cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) {
fillAssistDiskSource.storeLastFetchTimestamp(
serverUrl = serverUrl,
timestamp = clock.millis(),
)
return@runCatching
}
val formsUrl = serverUrl.trimEnd('/') + "/" +
(versionEntry.filename ?: "forms.$CURRENT_FORMS_VERSION.json")
val forms = fillAssistService
.getForms(formsUrl = formsUrl)
.getOrThrow()
val schemaMajor = forms.schemaVersion?.substringBefore('.')
if (schemaMajor != EXPECTED_SCHEMA_MAJOR) {
Timber.w("Unsupported fill-assist schema version: ${forms.schemaVersion}")
fillAssistDiskSource.storeLastFetchTimestamp(
serverUrl = serverUrl,
timestamp = clock.millis(),
)
return@runCatching
}
val rules = parseForms(forms)
fillAssistDiskSource.storeFillAssistRules(serverUrl = serverUrl, rules = rules)
fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = cid)
fillAssistDiskSource.storeLastFetchTimestamp(
serverUrl = serverUrl,
timestamp = clock.millis(),
)
}.also { result ->
result.onFailure { Timber.w(it, "Fill-assist sync failed") }
}
override fun getFillAssistRules(): FillAssistRules? {
val environment =
serverConfigRepository.serverConfigStateFlow.value?.serverData?.environment
val serverUrl = environment?.fillAssistRulesUrl ?: return null
return fillAssistDiskSource.getFillAssistRules(serverUrl = serverUrl)
}
}
// region CSS parser
private fun parseForms(forms: FillAssistFormsJson): FillAssistRules {
val hostRules = forms.hosts
?.mapNotNull { (hostname, hostEntry) -> hostEntry?.let { hostname to parseHostEntry(it) } }
?.filter { (_, rules) -> rules.isNotEmpty() }
?.toMap()
.orEmpty()
return FillAssistRules(hostRules = hostRules)
}
private fun parseHostEntry(
hostEntry: FillAssistFormsJson.HostEntryJson,
): List<FillAssistRules.HostRule> {
val allForms = buildList {
addAll(hostEntry.forms.orEmpty())
hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms.orEmpty()) }
}.distinct()
return buildFieldsByCategory(allForms).map { (category, fields) ->
FillAssistRules.HostRule(
category = category,
fields = fields.mapValues { (_, selectors) -> selectors.distinct() },
)
}
}
private fun buildFieldsByCategory(
forms: List<FillAssistFormsJson.FormJson>,
): Map<String, MutableMap<String, MutableList<SelectorClause>>> {
val result = mutableMapOf<String, MutableMap<String, MutableList<SelectorClause>>>()
forms.mapNotNull { form -> form.category?.let { form to it } }
.forEach { (form, category) ->
val parsedFields = form.fields.orEmpty()
.mapValues { (_, elem) -> parseCompositeSelectorArray(elem) }
.filterValues { it.isNotEmpty() }
.takeIf { it.isNotEmpty() } ?: return@forEach
val categoryFields = result.getOrPut(category) { mutableMapOf() }
parsedFields.forEach { (fieldKey, selectors) ->
categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors)
}
}
return result
}
private fun parseCompositeSelectorArray(element: JsonElement): List<SelectorClause> {
if (element !is JsonArray) return emptyList()
val result = mutableListOf<SelectorClause>()
for (item in element) {
when (item) {
is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) }
is JsonArray -> item
.filterIsInstance<JsonPrimitive>()
.mapNotNull { parseSingleSelector(it.content) }
.forEach { result.add(it) }
else -> Unit
}
}
return result
}
internal fun parseSingleSelector(selector: String): SelectorClause? {
// For shadow DOM / iframe selectors (>>>), extract the last segment — the actual target
// element. Android's autofill framework may expose these elements via htmlInfo when they
// are reachable (e.g. open shadow roots), so we parse their attributes for matching.
val effective = if (selector.contains(">>>")) {
selector.substringAfterLast(">>>").trim()
} else {
selector
}
if (effective.trimStart().startsWith(".")) return null
val tag = TAG_REGEX.find(effective)?.groupValues?.get(1)
var id: String? = null
var name: String? = null
var type: String? = null
var role: String? = null
ATTRIBUTE_REGEX.findAll(effective).forEach { match ->
val attrName = match.groupValues[1]
val attrValue = match.groupValues[2]
when (attrName) {
"id" -> id = attrValue
"name" -> name = attrValue
"type" -> type = attrValue
"role" -> role = attrValue
}
}
// Fallback: extract id from #shorthand (e.g. input#oid) when not present as [id='...'].
if (id == null) {
id = ID_SHORTHAND_REGEX.find(effective)?.groupValues?.get(1)
}
return SelectorClause(tag = tag, id = id, name = name, type = type, role = role)
}
// endregion

View File

@@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.autofill.model
import kotlinx.serialization.Serializable
/**
* Parsed, storage-ready representation of fill-assist targeting rules for all known hosts.
*
* @property hostRules Map of hostname (with optional port) to a list of [HostRule] entries.
* Multiple [HostRule] entries per host are possible when different pages define different forms.
*/
@Serializable
data class FillAssistRules(
val hostRules: Map<String, List<HostRule>>,
) {
/**
* Describes one parsed form for a host. Combines host-level and pathname-level forms into a
* single pooled representation so the consumer does not need to know the current URL path.
*
* @property category The form's purpose category (e.g. "account-login", "payment-card").
* @property fields Map of field key (e.g. "username", "password") to a list of
* [SelectorClause] alternatives. The first clause that matches a view node is used.
*/
@Serializable
data class HostRule(
val category: String,
val fields: Map<String, List<SelectorClause>>,
)
/**
* A single parsed CSS selector expressing HTML attribute constraints for matching a view node
* via [android.view.ViewStructure.HtmlInfo]. All non-null fields are AND constraints.
*
* @property tag The HTML tag name (e.g. "input", "select").
* @property id The value of the element's [id] attribute.
* @property name The value of the element's [name] attribute.
* @property type The value of the element's [type] attribute (e.g. "password", "text").
* @property role The value of the element's [role] attribute.
*/
@Serializable
data class SelectorClause(
val tag: String?,
val id: String?,
val name: String?,
val type: String?,
val role: String?,
)
}

View File

@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
@@ -32,6 +33,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformNetworkModule {
@Provides
@Singleton
fun providesFillAssistService(
bitwardenServiceClient: BitwardenServiceClient,
): FillAssistService = bitwardenServiceClient.fillAssistService
@Provides
@Singleton
fun providesConfigService(

View File

@@ -14,6 +14,7 @@ import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
@@ -78,6 +79,7 @@ class VaultSyncManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val fillAssistManager: FillAssistManager,
private val userLogoutManager: UserLogoutManager,
private val userStateManager: UserStateManager,
private val vaultLockManager: VaultLockManager,
@@ -340,6 +342,7 @@ class VaultSyncManagerImpl(
lastSyncTime = clock.instant(),
)
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
fillAssistManager.syncIfNecessary()
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true
SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
}

View File

@@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -224,6 +225,7 @@ object VaultManagerModule {
@Provides
@Singleton
fun provideVaultSyncManager(
fillAssistManager: FillAssistManager,
syncService: SyncService,
settingsDiskSource: SettingsDiskSource,
authDiskSource: AuthDiskSource,
@@ -237,6 +239,7 @@ object VaultManagerModule {
pushManager: PushManager,
dispatcherManager: DispatcherManager,
): VaultSyncManager = VaultSyncManagerImpl(
fillAssistManager = fillAssistManager,
syncService = syncService,
settingsDiskSource = settingsDiskSource,
authDiskSource = authDiskSource,

View File

@@ -8104,6 +8104,7 @@ class AuthRepositoryTest {
identityUrl = "mockIdentityUrl",
notificationsUrl = "mockNotificationsUrl",
ssoUrl = "mockSsoUrl",
fillAssistRulesUrl = null,
),
featureStates = emptyMap(),
communication = null,

View File

@@ -0,0 +1,135 @@
package com.x8bit.bitwarden.data.autofill.datasource.disk
import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class FillAssistDiskSourceTest {
private val fakeSharedPreferences = FakeSharedPreferences()
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private val diskSource = FillAssistDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
@Test
fun `migration clears all fill-assist data across all servers`() {
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc")
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = 123L)
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_2, rules = FILL_ASSIST_RULES)
// Trigger migration by writing a stale version — clears data for all servers.
fakeSharedPreferences.edit()
.putInt("bwPreferencesStorage:fillAssistCacheVersion", -1)
.apply()
val clearedDiskSource = FillAssistDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
assertNull(clearedDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
assertNull(clearedDiskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_2))
}
@Test
fun `storeFillAssistRules and getFillAssistRules round-trip`() {
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = null)
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
}
@Test
fun `data is scoped per server, one server does not affect another`() {
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_2))
assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
}
@Test
fun `storeLastKnownCid and getLastKnownCid round-trip`() {
val cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db"
assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = cid)
assertEquals(cid, diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = null)
assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
}
@Test
fun `storeLastFetchTimestamp and getLastFetchTimestamp round-trip`() {
val timestamp = 1716307262956L
assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = timestamp)
assertEquals(timestamp, diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = null)
assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
}
@Test
fun `migration preserves data when cache version is current`() {
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc")
// New instance with the same preferences — version already set to current by first init.
val sameDiskSource = FillAssistDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
assertEquals(FILL_ASSIST_RULES, sameDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
assertEquals("sha256:abc", sameDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
}
}
private const val SERVER_URL_1 = "https://fill-assist.example.com"
private const val SERVER_URL_2 = "https://fill-assist.other.com"
private val FILL_ASSIST_RULES = FillAssistRules(
hostRules = mapOf(
"example.com" to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "email",
name = null,
type = null,
role = null,
),
),
"password" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = "pass",
type = "password",
role = null,
),
),
),
),
),
),
)

View File

@@ -0,0 +1,708 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.datasource.disk.model.ServerConfig
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.model.ConfigResponseJson
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.service.FillAssistService
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
private const val BASE_URL = "https://fill-assist.example.com"
private const val MANIFEST_URL = "$BASE_URL/manifest.json"
private const val FORMS_URL = "$BASE_URL/forms.v0.json"
private const val CID = "sha256:abc123"
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2026-01-01T12:00:00Z"),
ZoneOffset.UTC,
)
/** A timestamp far in the past, ensuring the timestamp check never skips network calls. */
private const val STALE_TIMESTAMP = 0L
class FillAssistManagerTest {
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns true
}
private val serverConfigRepository: ServerConfigRepository = mockk {
every { serverConfigStateFlow } returns MutableStateFlow(SERVER_CONFIG)
}
private val fillAssistService: FillAssistService = mockk {
coEvery { getManifest(url = MANIFEST_URL) } returns Result.success(MANIFEST)
coEvery { getForms(formsUrl = FORMS_URL) } returns Result.success(FORMS_V1)
}
private val fillAssistDiskSource: FillAssistDiskSource = mockk {
every { getLastFetchTimestamp(BASE_URL) } returns STALE_TIMESTAMP
every { getLastKnownCid(BASE_URL) } returns null
every { getFillAssistRules(BASE_URL) } returns null
every { storeFillAssistRules(any(), any()) } just runs
every { storeLastKnownCid(any(), any()) } just runs
every { storeLastFetchTimestamp(any(), any()) } just runs
}
private val manager = FillAssistManagerImpl(
fillAssistService = fillAssistService,
fillAssistDiskSource = fillAssistDiskSource,
featureFlagManager = featureFlagManager,
serverConfigRepository = serverConfigRepository,
clock = FIXED_CLOCK,
dispatcherManager = FakeDispatcherManager(),
)
@BeforeEach
fun setUp() {
// serverConfigStateFlow replays its current value on subscription, triggering
// syncIfNecessary() during construction. Clear call counts for a clean test slate.
clearMocks(fillAssistService, fillAssistDiskSource, answers = false)
}
@Test
fun `sync returns success and does nothing when feature flag is disabled`() = runTest {
every {
featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)
} returns false
manager.syncIfNecessary()
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
}
@Test
fun `sync returns success and does nothing when fillAssistRulesUrl is null`() = runTest {
every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null)
manager.syncIfNecessary()
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
}
@Test
fun `sync skips all network calls when timestamp is fresh`() = runTest {
every {
fillAssistDiskSource.getLastFetchTimestamp(BASE_URL)
} returns FIXED_CLOCK.millis() - (6 * 60 * 60 * 1000L - 1)
manager.syncIfNecessary()
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
coVerify(exactly = 0) { fillAssistService.getForms(any()) }
}
@Test
fun `sync skips forms download and updates timestamp when CID is unchanged`() = runTest {
every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns CID
manager.syncIfNecessary()
coVerify(exactly = 1) { fillAssistService.getManifest(url = MANIFEST_URL) }
coVerify(exactly = 0) { fillAssistService.getForms(any()) }
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
}
@Test
fun `sync re-fetches forms when CID changes`() = runTest {
every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns "sha256:old"
manager.syncIfNecessary()
coVerify(exactly = 1) { fillAssistService.getForms(formsUrl = FORMS_URL) }
verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) }
verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) }
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
}
@Test
fun `sync does not store data when manifest fetch fails`() = runTest {
coEvery {
fillAssistService.getManifest(any())
} returns Result.failure(RuntimeException("network error"))
manager.syncIfNecessary()
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) }
verify(exactly = 0) { fillAssistDiskSource.storeLastFetchTimestamp(any(), any()) }
}
@Test
fun `sync does not store rules or cid when schemaVersion major is unsupported`() = runTest {
coEvery { fillAssistService.getForms(any()) } returns Result.success(
FORMS_V1.copy(schemaVersion = "1.0.0"),
)
manager.syncIfNecessary()
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) }
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
}
@Test
fun `sync happy path stores rules, cid, and timestamp`() = runTest {
manager.syncIfNecessary()
verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) }
verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) }
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
}
@Test
fun `sync pools forms from multiple pathnames under the same host`() = runTest {
coEvery {
fillAssistService.getForms(any())
} returns Result.success(FORMS_V1_MULTI_PATHNAME)
val rulesSlot = slot<FillAssistRules>()
every {
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
} just runs
manager.syncIfNecessary()
assertEquals(EXPECTED_RULES_MULTI_PATHNAME, rulesSlot.captured)
}
@Test
fun `sync pools host-level forms and pathname forms under the same host`() = runTest {
coEvery {
fillAssistService.getForms(any())
} returns Result.success(FORMS_V1_HOST_AND_PATHNAME)
val rulesSlot = slot<FillAssistRules>()
every {
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
} just runs
manager.syncIfNecessary()
assertEquals(EXPECTED_RULES_HOST_AND_PATHNAME, rulesSlot.captured)
}
@Test
fun `sync merges forms with the same category from different pathnames into one HostRule`() =
runTest {
coEvery {
fillAssistService.getForms(any())
} returns Result.success(FORMS_V1_SAME_CATEGORY_PATHNAMES)
val rulesSlot = slot<FillAssistRules>()
every {
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
} just runs
manager.syncIfNecessary()
assertEquals(EXPECTED_RULES_MERGED_CATEGORY, rulesSlot.captured)
}
@Test
fun `sync deduplicates selector clauses within a merged category`() = runTest {
coEvery {
fillAssistService.getForms(any())
} returns Result.success(FORMS_V1_DUPLICATE_SELECTORS)
val rulesSlot = slot<FillAssistRules>()
every {
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
} just runs
manager.syncIfNecessary()
assertEquals(EXPECTED_RULES_DEDUPLICATED_SELECTORS, rulesSlot.captured)
}
@Test
fun `getFillAssistRules delegates to disk source`() {
val expected = FillAssistRules(hostRules = emptyMap())
every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns expected
assertEquals(expected, manager.getFillAssistRules())
}
@Test
fun `getFillAssistRules returns null when disk source has no data`() {
every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns null
assertNull(manager.getFillAssistRules())
}
@Test
fun `getFillAssistRules returns null when server URL is not configured`() {
every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null)
assertNull(manager.getFillAssistRules())
}
// region CSS parser
@Test
fun `parseSingleSelector extracts tag and id shorthand`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = "oid",
name = null,
type = null,
role = null,
),
parseSingleSelector("input#oid"),
)
}
@Test
fun `parseSingleSelector extracts name attribute`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = "p",
type = null,
role = null,
),
parseSingleSelector("input[name='p']"),
)
}
@Test
fun `parseSingleSelector extracts compound selector with id shorthand and name`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = "password",
name = "password",
type = null,
role = null,
),
parseSingleSelector("input#password[name='password']"),
)
}
@Test
fun `parseSingleSelector extracts role attribute`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "form",
id = null,
name = null,
type = null,
role = "search",
),
parseSingleSelector("form[role='search']"),
)
}
@Test
fun `parseSingleSelector extracts last segment of shadow DOM selector`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = "field",
name = null,
type = null,
role = null,
),
parseSingleSelector("div#container >>> input#field"),
)
}
@Test
fun `parseSingleSelector extracts last segment of multi-level shadow DOM selector`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
name = "password",
id = null,
type = null,
role = null,
),
parseSingleSelector("div#form-container >>> form > div >>> input[name='password']"),
)
}
@Test
fun `parseSingleSelector returns null for pure class selector`() {
assertNull(parseSingleSelector(".loginForm"))
}
@Test
fun `parseSingleSelector handles select element`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "select",
id = "state",
name = null,
type = null,
role = null,
),
parseSingleSelector("select#state"),
)
}
// endregion
}
private val MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = null,
gitSha = null,
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v0" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v0.json",
cid = CID,
schema = null,
),
),
),
)
private val FORMS_V1 = FillAssistFormsJson(
schemaVersion = "0.1.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(
listOf(JsonPrimitive("input#user")),
),
),
),
),
pathnames = null,
),
),
)
// Host with two pathnames — both forms must appear in the stored rules.
private val FORMS_V1_MULTI_PATHNAME = FillAssistFormsJson(
schemaVersion = "0.1.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = null,
pathnames = mapOf(
"/login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
),
"/register" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-creation",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#email"))),
"newPassword" to JsonArray(listOf(JsonPrimitive("input#new-pass"))),
),
),
),
),
),
),
),
)
private val EXPECTED_RULES_MULTI_PATHNAME = FillAssistRules(
hostRules = mapOf(
"example.com" to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
),
"password" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "pass",
name = null,
type = null,
role = null,
),
),
),
),
FillAssistRules.HostRule(
category = "account-creation",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "email",
name = null,
type = null,
role = null,
),
),
"newPassword" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "new-pass",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
// Host with both top-level forms and pathname forms — both must appear in the stored rules.
private val FORMS_V1_HOST_AND_PATHNAME = FillAssistFormsJson(
schemaVersion = "0.1.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
),
),
),
pathnames = mapOf(
"/checkout" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "payment-card",
container = null,
fields = mapOf(
"cardNumber" to JsonArray(listOf(JsonPrimitive("input#card-num"))),
),
),
),
),
),
),
),
)
private val EXPECTED_RULES_HOST_AND_PATHNAME = FillAssistRules(
hostRules = mapOf(
"example.com" to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
),
),
),
FillAssistRules.HostRule(
category = "payment-card",
fields = mapOf(
"cardNumber" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "card-num",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
// Two pathnames both define account-login — must be merged into one HostRule.
private val FORMS_V1_SAME_CATEGORY_PATHNAMES = FillAssistFormsJson(
schemaVersion = "0.1.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = null,
pathnames = mapOf(
"/login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
),
),
),
),
"/signin" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#email"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
),
),
),
),
)
private val EXPECTED_RULES_MERGED_CATEGORY = FillAssistRules(
hostRules = mapOf(
"example.com" to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
FillAssistRules.SelectorClause(
tag = "input",
id = "email",
name = null,
type = null,
role = null,
),
),
"password" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "pass",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
// Two pathnames define the same selector — the duplicate must be removed.
private val FORMS_V1_DUPLICATE_SELECTORS = FillAssistFormsJson(
schemaVersion = "0.1.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = null,
pathnames = mapOf(
"/login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
),
),
),
),
"/other-login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
),
),
),
),
),
),
),
)
private val EXPECTED_RULES_DEDUPLICATED_SELECTORS = FillAssistRules(
hostRules = mapOf(
"example.com" to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
private val SERVER_CONFIG = ServerConfig(
lastSync = 0L,
serverData = ConfigResponseJson(
type = null,
version = null,
gitHash = null,
server = null,
environment = ConfigResponseJson.EnvironmentJson(
cloudRegion = null,
vaultUrl = null,
apiUrl = null,
identityUrl = null,
notificationsUrl = null,
ssoUrl = null,
fillAssistRulesUrl = BASE_URL,
),
featureStates = null,
communication = null,
),
)

View File

@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
identityUrl = null,
notificationsUrl = null,
ssoUrl = null,
fillAssistRulesUrl = null,
),
featureStates = null,
communication = ConfigResponseJson.CommunicationJson(

View File

@@ -319,6 +319,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
@@ -143,7 +144,12 @@ class VaultSyncManagerTest {
every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow
}
private val fillAssistManager: FillAssistManager = mockk {
every { syncIfNecessary() } just runs
}
private val vaultSyncManager: VaultSyncManager = VaultSyncManagerImpl(
fillAssistManager = fillAssistManager,
syncService = syncService,
settingsDiskSource = settingsDiskSource,
authDiskSource = fakeAuthDiskSource,

View File

@@ -262,6 +262,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -107,6 +107,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -161,6 +161,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
@@ -185,6 +186,7 @@ private val CONFIG_RESPONSE_JSON = ConfigResponseJson(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
@@ -107,6 +108,11 @@ interface BitwardenServiceClient {
*/
val eventService: EventService
/**
* Provides access to the Fill-Assist service.
*/
val fillAssistService: FillAssistService
/**
* Provides access to the Folder service.
*/

View File

@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FillAssistServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
@@ -155,6 +157,12 @@ internal class BitwardenServiceClientImpl(
)
}
override val fillAssistService: FillAssistService by lazy {
FillAssistServiceImpl(
api = retrofits.createStaticRetrofit().create(),
)
}
override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy {
HaveIBeenPwnedServiceImpl(
api = retrofits

View File

@@ -0,0 +1,29 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.model.NetworkResult
import retrofit2.http.GET
import retrofit2.http.Url
/**
* Defines endpoints for retrieving fill-assist targeting rules from the fill-assist service.
* Uses [Url] to support the dynamic base URL provided by server config at runtime.
*/
internal interface FillAssistApi {
/**
* Fetches the fill-assist manifest from the given [url].
*/
@GET
suspend fun getManifest(
@Url url: String,
): NetworkResult<FillAssistManifestJson>
/**
* Fetches and decodes the forms rules file from [url].
*/
@GET
suspend fun getForms(
@Url url: String,
): NetworkResult<FillAssistFormsJson>
}

View File

@@ -82,6 +82,9 @@ data class ConfigResponseJson(
@SerialName("sso")
val ssoUrl: String?,
@SerialName("fillAssistRules")
val fillAssistRulesUrl: String?,
)
/**

View File

@@ -0,0 +1,68 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Represents the fill-assist forms rules file.
*
* @property schemaVersion The semantic version string for this file (e.g. "1.0.0").
* @property hosts Map of hostname (optionally with port) to [HostEntryJson], or null if the host
* is explicitly excluded from fill-assist.
*/
@Serializable
data class FillAssistFormsJson(
@SerialName("schemaVersion")
val schemaVersion: String? = null,
@SerialName("hosts")
val hosts: Map<String, HostEntryJson?>? = null,
) {
/**
* Form descriptions and pathname-specific overrides for a single host.
*
* @property forms Site-wide fallback form descriptions.
* @property pathnames Pathname-specific overrides; a null value means that path is excluded.
*/
@Serializable
data class HostEntryJson(
@SerialName("forms")
val forms: List<FormJson>? = null,
@SerialName("pathnames")
val pathnames: Map<String, PathnameEntryJson?>? = null,
)
/**
* Form descriptions for a specific pathname.
*
* @property forms The form descriptions for this path.
*/
@Serializable
data class PathnameEntryJson(
@SerialName("forms")
val forms: List<FormJson>? = null,
)
/**
* Describes one logical form on a page.
*
* @property category The categorical purpose of this form (e.g. "account-login").
* @property container Optional CSS selectors identifying the form's container element.
* @property fields Map of field key to [JsonElement] representing a compositeSelectorArray.
* Each array element is either a CSS selector string or an array of strings for composite
* multi-input fields. Unknown fields are gracefully ignored via [ignoreUnknownKeys].
*/
@Serializable
data class FormJson(
@SerialName("category")
val category: String? = null,
@SerialName("container")
val container: List<String>? = null,
@SerialName("fields")
val fields: Map<String, JsonElement>? = null,
)
}

View File

@@ -0,0 +1,64 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the fill-assist manifest returned by the fill-assist service.
*
* @property buildId The unique identifier for this build.
* @property timestamp The ISO-8601 timestamp when this build was produced.
* @property gitSha The git commit SHA for this build.
* @property maps The map data entries keyed by map type.
*/
@Serializable
data class FillAssistManifestJson(
@SerialName("buildId")
val buildId: String? = null,
@SerialName("timestamp")
val timestamp: String? = null,
@SerialName("gitSha")
val gitSha: String? = null,
@SerialName("maps")
val maps: MapsJson? = null,
) {
/**
* Container for all available maps.
*
* @property forms Map of schema version string (e.g. "v1", "v2") to [FileEntryJson].
* Using a [Map] allows new versions to appear automatically without model changes.
*/
@Serializable
data class MapsJson(
@SerialName("forms")
val forms: Map<String, FileEntryJson?>?,
)
/**
* Metadata for a single versioned file in a map.
*
* @property filename The filename to fetch (e.g. "forms.v0.json").
* @property cid The SHA-256 content hash in "sha256:<hex>" format. Used as a staleness key
* to detect when the forms file has changed on the server, avoiding unnecessary re-downloads.
* @property schema The schema filename associated with this file version.
* @property deprecated When true, this version has entered its end-of-life support window.
* Consumers should plan migration but may continue using the version until it is removed.
*/
@Serializable
data class FileEntryJson(
@SerialName("filename")
val filename: String? = null,
@SerialName("cid")
val cid: String? = null,
@SerialName("schema")
val schema: String? = null,
@SerialName("deprecated")
val deprecated: Boolean? = null,
)
}

View File

@@ -0,0 +1,22 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
/**
* Provides access to the fill-assist targeting rules service.
*/
interface FillAssistService {
/**
* Fetches and parses the fill-assist manifest from [url].
*/
suspend fun getManifest(url: String): Result<FillAssistManifestJson>
/**
* Downloads and parses the forms rules file from [formsUrl].
*
* Returns [Result.failure] if the network request fails or parsing fails.
* Version-agnostic: any forms file URL can be passed regardless of schema version.
*/
suspend fun getForms(formsUrl: String): Result<FillAssistFormsJson>
}

View File

@@ -0,0 +1,20 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.util.toResult
/**
* Default implementation of [FillAssistService].
*/
internal class FillAssistServiceImpl(
private val api: FillAssistApi,
) : FillAssistService {
override suspend fun getManifest(url: String): Result<FillAssistManifestJson> =
api.getManifest(url = url).toResult()
override suspend fun getForms(formsUrl: String): Result<FillAssistFormsJson> =
api.getForms(url = formsUrl).toResult()
}

View File

@@ -0,0 +1,305 @@
package com.bitwarden.network.model
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class FillAssistFormsJsonTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `deserialize simple login form`() {
assertEquals(
SIMPLE_LOGIN_FORMS,
json.decodeFromString<FillAssistFormsJson>(SIMPLE_LOGIN_JSON),
)
}
@Test
fun `deserialize host with null value is excluded host`() {
val result = json.decodeFromString<FillAssistFormsJson>(NULL_HOST_JSON)
assertNull(result.hosts?.get("excluded.com"))
}
@Test
fun `deserialize null pathname is excluded path`() {
val result = json.decodeFromString<FillAssistFormsJson>(NULL_PATHNAME_JSON)
assertNull(result.hosts?.get("example.com")?.pathnames?.get("/excluded"))
}
@Test
fun `deserialize composite OTP field`() {
assertEquals(
OTP_FORMS,
json.decodeFromString<FillAssistFormsJson>(OTP_JSON),
)
}
@Test
fun `deserialize compound selector with multiple alternatives`() {
assertEquals(
HONEYPOT_FORMS,
json.decodeFromString<FillAssistFormsJson>(HONEYPOT_JSON),
)
}
@Test
fun `deserialize form with container`() {
assertEquals(
CONTAINER_FORMS,
json.decodeFromString<FillAssistFormsJson>(CONTAINER_JSON),
)
}
@Test
fun `deserialize form with actions field is graceful`() {
// actions is intentionally excluded from FormJson — handled by ignoreUnknownKeys.
assertEquals(
SIMPLE_LOGIN_FORMS,
json.decodeFromString<FillAssistFormsJson>(SIMPLE_LOGIN_WITH_ACTIONS_JSON),
)
}
}
private val SIMPLE_LOGIN_FORMS = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#username"))),
"password" to JsonArray(listOf(JsonPrimitive("input#password"))),
),
),
),
pathnames = null,
),
),
)
private val OTP_FORMS = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = null,
pathnames = mapOf(
"/login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"oneTimeCode" to JsonArray(
listOf(
JsonArray(
listOf(
JsonPrimitive("input[name='otp-0']"),
JsonPrimitive("input[name='otp-1']"),
JsonPrimitive("input[name='otp-2']"),
JsonPrimitive("input[name='otp-3']"),
JsonPrimitive("input[name='otp-4']"),
JsonPrimitive("input[name='otp-5']"),
),
),
),
),
),
),
),
),
),
),
),
)
private val HONEYPOT_FORMS = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(
listOf(
JsonPrimitive("input#password[name='password']"),
JsonPrimitive("input[name='password']"),
),
),
"password" to JsonArray(
listOf(
JsonPrimitive("input#username[name='username']"),
JsonPrimitive("input[name='username']"),
),
),
),
),
),
pathnames = null,
),
),
)
private val CONTAINER_FORMS = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = null,
pathnames = mapOf(
"/login" to FillAssistFormsJson.PathnameEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = listOf("div#login-container"),
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
),
),
),
),
)
private const val SIMPLE_LOGIN_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#username"],
"password": ["input#password"]
}
}
]
}
}
}
"""
private const val NULL_HOST_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"excluded.com": null
}
}
"""
private const val NULL_PATHNAME_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"pathnames": {
"/excluded": null
}
}
}
}
"""
private const val OTP_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"pathnames": {
"/login": {
"forms": [
{
"category": "account-login",
"fields": {
"oneTimeCode": [
["input[name='otp-0']","input[name='otp-1']","input[name='otp-2']",
"input[name='otp-3']","input[name='otp-4']","input[name='otp-5']"]
]
}
}
]
}
}
}
}
}
"""
private const val HONEYPOT_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#password[name='password']", "input[name='password']"],
"password": ["input#username[name='username']", "input[name='username']"]
}
}
]
}
}
}
"""
private const val CONTAINER_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"pathnames": {
"/login": {
"forms": [
{
"category": "account-login",
"container": ["div#login-container"],
"fields": {
"username": ["input#user"],
"password": ["input#pass"]
}
}
]
}
}
}
}
}
"""
// Same structure as SIMPLE_LOGIN_JSON with the actions field present — verifies that
// actions (intentionally omitted from FormJson) is handled by ignoreUnknownKeys = true.
private const val SIMPLE_LOGIN_WITH_ACTIONS_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#username"],
"password": ["input#password"]
},
"actions": {
"submit": ["button#submit"]
}
}
]
}
}
}
"""

View File

@@ -0,0 +1,165 @@
package com.bitwarden.network.model
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class FillAssistManifestJsonTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `deserialize full manifest with v0 entry`() {
assertEquals(
FULL_MANIFEST,
json.decodeFromString<FillAssistManifestJson>(MANIFEST_JSON),
)
}
@Test
fun `deserialize manifest with multiple version entries`() {
assertEquals(
MULTI_VERSION_MANIFEST,
json.decodeFromString<FillAssistManifestJson>(MANIFEST_MULTI_VERSION_JSON),
)
}
@Test
fun `deserialize manifest with unknown top-level fields is graceful`() {
assertEquals(
FULL_MANIFEST,
json.decodeFromString<FillAssistManifestJson>(MANIFEST_EXTRA_FIELDS_JSON),
)
}
@Test
fun `deserialize minimal manifest with null fields`() {
val result = json.decodeFromString<FillAssistManifestJson>("{}")
assertNull(result.buildId)
assertNull(result.maps)
}
@Test
fun `deserialize manifest with deprecated version entry`() {
assertEquals(
DEPRECATED_MANIFEST,
json.decodeFromString<FillAssistManifestJson>(MANIFEST_DEPRECATED_JSON),
)
}
}
private val FULL_MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = "2026-05-20T15:01:02.956Z",
gitSha = "abc123",
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v0" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v0.json",
cid = "sha256:abc123def456",
schema = "forms.v0.schema.json",
),
),
),
)
private val MULTI_VERSION_MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = null,
gitSha = null,
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v0" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v0.json",
cid = "sha256:aaa",
schema = "forms.v0.schema.json",
),
"v1" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v1.json",
cid = "sha256:bbb",
schema = "forms.v1.schema.json",
),
),
),
)
private const val MANIFEST_JSON = """
{
"buildId": "local-build",
"timestamp": "2026-05-20T15:01:02.956Z",
"gitSha": "abc123",
"maps": {
"forms": {
"v0": {
"filename": "forms.v0.json",
"cid": "sha256:abc123def456",
"schema": "forms.v0.schema.json"
}
}
}
}
"""
private const val MANIFEST_MULTI_VERSION_JSON = """
{
"buildId": "local-build",
"maps": {
"forms": {
"v0": { "filename": "forms.v0.json", "cid": "sha256:aaa", "schema": "forms.v0.schema.json" },
"v1": { "filename": "forms.v1.json", "cid": "sha256:bbb", "schema": "forms.v1.schema.json" }
}
}
}
"""
private val DEPRECATED_MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = null,
gitSha = null,
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v0" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v0.json",
cid = "sha256:abc123def456",
schema = "forms.v0.schema.json",
deprecated = true,
),
),
),
)
private const val MANIFEST_DEPRECATED_JSON = """
{
"buildId": "local-build",
"maps": {
"forms": {
"v0": {
"filename": "forms.v0.json",
"cid": "sha256:abc123def456",
"schema": "forms.v0.schema.json",
"deprecated": true
}
}
}
}
"""
// Same structure as MANIFEST_JSON with an extra unknown key — verifies ignoreUnknownKeys = true.
private const val MANIFEST_EXTRA_FIELDS_JSON = """
{
"buildId": "local-build",
"timestamp": "2026-05-20T15:01:02.956Z",
"gitSha": "abc123",
"checksums": "ignored",
"maps": {
"forms": {
"v0": {
"filename": "forms.v0.json",
"cid": "sha256:abc123def456",
"schema": "forms.v0.schema.json"
}
}
}
}
"""

View File

@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
notificationsUrl = "notificationsUrl",
identityUrl = "identityUrl",
ssoUrl = "ssoUrl",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"feature one" to JsonPrimitive(false),

View File

@@ -0,0 +1,115 @@
package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class FillAssistServiceTest : BaseServiceTest() {
private val api: FillAssistApi = retrofit.create()
private val service = FillAssistServiceImpl(api = api)
@Test
fun `getManifest should parse manifest response`() = runTest {
server.enqueue(MockResponse().setBody(MANIFEST_JSON))
assertEquals(MANIFEST.asSuccess(), service.getManifest(url = "$urlPrefix/manifest.json"))
}
@Test
fun `getManifest should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
assertTrue(service.getManifest(url = "$urlPrefix/manifest.json").isFailure)
}
@Test
fun `getForms should parse and return forms`() = runTest {
server.enqueue(MockResponse().setBody(FORMS_V1_JSON))
assertEquals(FORMS_V1.asSuccess(), service.getForms(formsUrl = "$urlPrefix/forms.v1.json"))
}
@Test
fun `getForms should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
assertTrue(service.getForms(formsUrl = "$urlPrefix/forms.v1.json").isFailure)
}
}
private val MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = "2026-05-20T15:01:02.956Z",
gitSha = "abc123",
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v1" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v1.json",
cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
schema = "forms.v1.schema.json",
),
),
),
)
private val FORMS_V1 = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
pathnames = null,
),
),
)
private const val MANIFEST_JSON = """
{
"buildId": "local-build",
"timestamp": "2026-05-20T15:01:02.956Z",
"gitSha": "abc123",
"maps": {
"forms": {
"v1": {
"filename": "forms.v1.json",
"cid": "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
"schema": "forms.v1.schema.json"
}
}
}
}
"""
private const val FORMS_V1_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#user"],
"password": ["input#pass"]
}
}
]
}
}
}
"""