Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.sentry.compose.navigation3

import io.sentry.ILogger
import io.sentry.SentryLevel.WARNING
import io.sentry.compose.navigation3.ArgumentDropReason.Companion.ARGUMENT_DROP_REASON_KEY
import io.sentry.compose.navigation3.NormalizedSentryBackStackEntry.Companion.UNKNOWN_ENTRY_NAME
import io.sentry.util.ExceptionUtils
import java.util.IdentityHashMap
Expand Down Expand Up @@ -69,21 +70,33 @@ internal class BackStackConverter<T : Any>(
// Back stack entry mappers are host app callbacks.
ExceptionUtils.rethrowIfFatal(t)
warningState.logMapperFailureWarning(logger, t)
return NormalizedSentryBackStackEntry(UNKNOWN_ENTRY_NAME)
return NormalizedSentryBackStackEntry(
name = UNKNOWN_ENTRY_NAME,
argumentDropReason = ArgumentDropReason.MAPPING_FAILED,
)
}

if (info == null) {
return NormalizedSentryBackStackEntry(UNKNOWN_ENTRY_NAME)
return NormalizedSentryBackStackEntry(name = UNKNOWN_ENTRY_NAME)
}

val arguments = info.arguments?.let(sanitizer::sanitizeEntry) ?: emptyMap()
val sanitizedArguments =
info.arguments?.let(sanitizer::sanitizeEntry) ?: SanitizedArguments(emptyMap())
val formattedName = NormalizedSentryBackStackEntry.formatName(info.name)

return if (formattedName.isBlank()) {
warningState.logInvalidNameWarning(logger)
NormalizedSentryBackStackEntry(UNKNOWN_ENTRY_NAME, arguments)
NormalizedSentryBackStackEntry(
name = UNKNOWN_ENTRY_NAME,
arguments = sanitizedArguments.values,
argumentDropReason = sanitizedArguments.dropReason,
)
} else {
NormalizedSentryBackStackEntry(formattedName, arguments)
NormalizedSentryBackStackEntry(
name = formattedName,
arguments = sanitizedArguments.values,
argumentDropReason = sanitizedArguments.dropReason,
)
}
}

Expand All @@ -109,6 +122,11 @@ internal class BackStackConverter<T : Any>(
KEEP_LAST,
}

internal data class SanitizedArguments(
val values: Map<String, Any?>,
val dropReason: ArgumentDropReason? = null,
)

/**
* Sanitizes a back stack entry's arguments and writes them in a serializable form. It bounds
* depth and total value count, and it rejects cyclic structures.
Expand All @@ -124,32 +142,33 @@ internal class BackStackConverter<T : Any>(

private val activeContainers = IdentityHashMap<Any, Unit>()
private var remainingValues = MAX_ARGUMENT_COUNT
private var budgetExhausted = false
private var remainingCharacters = MAX_ARGUMENT_CHARACTERS
private var dropReason: ArgumentDropReason? = null

/**
* Sanitizes one entry's arguments, or returns an empty map to drop them, either because the
* structure is cyclic or too deeply nested (this entry only), or because the shared per-update
* value budget is spent (this entry and every older one).
*/
@Suppress("TooGenericExceptionCaught")
fun sanitizeEntry(raw: Map<String, Any?>): Map<String, Any?> {
if (budgetExhausted) {
return emptyMap()
fun sanitizeEntry(raw: Map<String, Any?>): SanitizedArguments {
dropReason?.let { reason ->
return SanitizedArguments(emptyMap(), reason)
}

return try {
sanitizeMap(raw, depth = 0)
SanitizedArguments(sanitizeMap(raw, depth = 0))
} catch (drop: DropSubtree) {
if (drop.exhaustsBudget) {
budgetExhausted = true
if (drop.reason.exhaustsUpdateBudget) {
dropReason = drop.reason
}
logger.log(WARNING, drop.warning)
emptyMap()
SanitizedArguments(emptyMap(), drop.reason)
} catch (t: Throwable) {
// Extracted maps may invoke host app code while iterating or stringifying values.
ExceptionUtils.rethrowIfFatal(t)
logger.log(WARNING, STRUCTURE_WARNING, t)
emptyMap()
SanitizedArguments(emptyMap(), ArgumentDropReason.SANITIZATION_FAILED)
}
}

Expand All @@ -158,7 +177,9 @@ internal class BackStackConverter<T : Any>(
try {
val sanitized = mutableMapOf<String, Any?>()
for ((key, childValue) in value) {
sanitized[key.toString()] = sanitizeValue(childValue, depth + 1)
val keyString = key.toString()
consumeCharacters(keyString.length)
sanitized[keyString] = sanitizeValue(childValue, depth + 1)
}
return sanitized
} finally {
Expand All @@ -185,17 +206,26 @@ internal class BackStackConverter<T : Any>(
val collection = value?.asSanitizableCollectionOrNull()

return when {
value == null || value is String || value is Number || value is Boolean -> value
value is CharSequence || value is Char -> value.toString()
value is Enum<*> -> value.name
value is Map<*, *> -> sanitizeMap(value, depth)
collection != null -> sanitizeCollection(collection, depth)
else -> sanitizeScalar(value)
}
}

private fun sanitizeScalar(value: Any?): Any? =
when (value) {
null,
is Number,
is Boolean -> value
is String -> value.also { consumeCharacters(it.length) }
is CharSequence,
is Char -> value.toString().also { consumeCharacters(it.length) }
is Enum<*> -> value.name.also { consumeCharacters(it.length) }
else -> {
warningState.logUnsupportedValueWarning(value::class.simpleName, logger)
value.toString()
value.toString().also { consumeCharacters(it.length) }
}
}
}

private fun Any.asSanitizableCollectionOrNull(): Collection<*>? =
when (this) {
Expand All @@ -218,16 +248,23 @@ internal class BackStackConverter<T : Any>(
*/
private fun visit(depth: Int) {
if (depth > MAX_ARGUMENT_DEPTH) {
throw DropSubtree(STRUCTURE_WARNING, exhaustsBudget = false)
throw DropSubtree(STRUCTURE_WARNING, ArgumentDropReason.INVALID_STRUCTURE)
}
if (--remainingValues < 0) {
throw DropSubtree(BUDGET_WARNING, exhaustsBudget = true)
throw DropSubtree(MAX_COUNT_WARNING, ArgumentDropReason.MAX_COUNT)
}
}

private fun consumeCharacters(count: Int) {
if (count > remainingCharacters) {
throw DropSubtree(MAX_CHARACTER_WARNING, ArgumentDropReason.CHARACTER_LIMIT)
}
remainingCharacters -= count
}

private fun enter(container: Any) {
if (activeContainers.put(container, Unit) != null) {
throw DropSubtree(STRUCTURE_WARNING, exhaustsBudget = false)
throw DropSubtree(STRUCTURE_WARNING, ArgumentDropReason.INVALID_STRUCTURE)
}
}

Expand All @@ -239,11 +276,10 @@ internal class BackStackConverter<T : Any>(
* Control-flow signal to abort sanitization of the current subtree. Internal to
* [ArgumentSanitizer].
*
* [exhaustsBudget] distinguishes an entry-local drop (cycle or over-deep structure) from an
* update-wide one (the shared value budget is spent). Overrides [fillInStackTrace] to skip
* stack-trace capture.
* [reason] distinguishes entry-local drops from update-wide budget exhaustion. Overrides
* [fillInStackTrace] to skip stack-trace capture.
*/
private class DropSubtree(val warning: String, val exhaustsBudget: Boolean) :
private class DropSubtree(val warning: String, val reason: ArgumentDropReason) :
RuntimeException() {
override fun fillInStackTrace(): Throwable = this
}
Expand Down Expand Up @@ -279,11 +315,22 @@ internal class BackStackConverter<T : Any>(
*
* Then /ProductDetail and /Home will have no arguments, but /Checkout will.
*/
private const val MAX_ARGUMENT_COUNT = 500
private const val MAX_ARGUMENT_COUNT = 200

/**
* Max number of characters visited while sanitizing all entries in a given back stack update.
*
* Caps payload size in the presence of large individual arguments.
*/
private const val MAX_ARGUMENT_CHARACTERS = 4_096

private const val MAX_CHARACTER_WARNING =
"Nav3 arguments exceeded the maximum total character count for one backstack update. Skipping " +
"arguments for this and older captured entries."

private const val BUDGET_WARNING =
"Nav3 arguments exceeded the maximum total value count for one backstack update. Skipping arguments " +
"for this and older captured entries."
private const val MAX_COUNT_WARNING =
"Nav3 arguments exceeded the maximum total count for one backstack update. Skipping arguments for " +
"this and older captured entries."

private const val STRUCTURE_WARNING =
"Nav3 argument sanitization failed (possibly a cyclic or deeply nested structure). Skipping arguments."
Expand Down Expand Up @@ -348,6 +395,8 @@ internal data class NormalizedSentryBackStackEntry(
val name: String,
/** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */
val arguments: Map<String, Any?> = emptyMap(),
/** The reason why the SDK dropped host-provided arguments. */
val argumentDropReason: ArgumentDropReason? = null,
) {

companion object {
Expand All @@ -369,6 +418,15 @@ internal data class NormalizedSentryBackStackEntry(
}
}

/** Returns sanitized host arguments together with SDK-owned argument metadata. */
fun argumentsWithMetadata(): Map<String, Any?> {
val reason = argumentDropReason ?: return arguments
return buildMap {
putAll(arguments)
put(ARGUMENT_DROP_REASON_KEY, reason.serializedValue)
}
}

/**
* Returns this entry in serialized form. E.g.:
* ```
Expand All @@ -383,11 +441,26 @@ internal data class NormalizedSentryBackStackEntry(
*/
fun serialize(): Map<String, Any?> = buildMap {
put("entry", name)
if (arguments.isNotEmpty()) {
put("arguments", arguments)
}
argumentsWithMetadata().takeIf { it.isNotEmpty() }?.let { put("arguments", it) }
}
}

internal fun List<NormalizedSentryBackStackEntry>.serialize(): List<Map<String, Any?>> =
map(NormalizedSentryBackStackEntry::serialize)

/** Why host-provided arguments were unavailable in emitted navigation data. */
internal enum class ArgumentDropReason(
val serializedValue: String,
val exhaustsUpdateBudget: Boolean = false,
) {

CHARACTER_LIMIT("max_character_limit_exceeded", exhaustsUpdateBudget = true),
INVALID_STRUCTURE("invalid_structure"),
MAPPING_FAILED("mapping_failed"),
MAX_COUNT("max_argument_count_exceeded", exhaustsUpdateBudget = true),
SANITIZATION_FAILED("sanitization_failed");

companion object {
const val ARGUMENT_DROP_REASON_KEY = "arguments_dropped_by_sentry"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ internal class BackStackObserver<T : Any>(
.start(
scope,
currentTop.name,
currentTop.arguments,
currentTop.argumentsWithMetadata(),
)
?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) }
} else {
Expand Down Expand Up @@ -455,17 +455,23 @@ private class NavBreadcrumbs(private val scopes: IScopes) {
type = NAVIGATION_OP
category = NAVIGATION_OP

fromEntry?.let {
data["from"] = it.name
if (it.arguments.isNotEmpty()) {
data["from_arguments"] = it.arguments
}
fromEntry?.let { entry ->
data["from"] = entry.name
entry
.argumentsWithMetadata()
.takeIf { it.isNotEmpty() }
?.let { arguments ->
data["from_arguments"] = arguments
}
}

data["to"] = toEntry.name
if (toEntry.arguments.isNotEmpty()) {
data["to_arguments"] = toEntry.arguments
}
toEntry
.argumentsWithMetadata()
.takeIf { it.isNotEmpty() }
?.let { arguments ->
data["to_arguments"] = arguments
}

level = INFO
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,12 @@ internal fun <T : Any> SentryNavEffect(
)
}

// The incoming back stack is mutable and shared with the host app; copy it so that BackStackKey
// and BackStackObserver are guaranteed to have the same (stable) view.
// Intentionally don't remember this copy. Snapshot-backed lists mutate in place, so
// remember(backStack) { backStack.toList() } would cache a stale copy. (The key reference
// retained by remember() and the backStack reference passed to this effect would point to the
// same instance, causing remember() to always return the originally copied list.) Making a fresh
// copy ensures a stable snapshot for the duration of each update and lets BackStackKey compare
// it with the previous one.
val copy = backStack.toList()

DisposableEffect(observer, BackStackKey(copy)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.jetbrains.annotations.ApiStatus

// Keep the default low: every captured entry may require argument extraction and recursive
// sanitization when navigation changes are observed.
private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 10
private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 5

/**
* Configuration info for a [SentryNavEffect].
Expand Down
Loading
Loading