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 @@ -25,6 +25,7 @@ internal data class HttpTransactionTuple(
@ColumnInfo(name = "error") var error: String?,
@ColumnInfo(name = "graphQlDetected") var graphQlDetected: Boolean = false,
@ColumnInfo(name = "graphQlOperationName") var graphQlOperationName: String?,
@ColumnInfo(name = "requestContentType") var requestContentType: String?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to bump the DB version because of this

) {
val isSsl: Boolean get() = scheme.equals("https", ignoreCase = true)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal class HttpTransactionDatabaseRepository(
* more context
*/
graphQlQuery = pathQuery,
contentTypeQuery = pathQuery,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,25 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple
internal interface HttpTransactionDao {
@Query(
"SELECT id, requestDate, tookMs, protocol, method, host, path, scheme, responseCode, " +
"requestPayloadSize, responsePayloadSize, error, graphQLDetected, graphQlOperationName FROM " +
"requestPayloadSize, responsePayloadSize, error, graphQLDetected, " +
"graphQlOperationName, requestContentType FROM " +
"transactions ORDER BY requestDate DESC",
)
fun getSortedTuples(): LiveData<List<HttpTransactionTuple>>

@Query(
"SELECT id, requestDate, tookMs, protocol, method, host, path, scheme, responseCode, " +
"requestPayloadSize, responsePayloadSize, error, graphQLDetected, graphQlOperationName FROM " +
"requestPayloadSize, responsePayloadSize, error, graphQLDetected, " +
"graphQlOperationName, requestContentType FROM " +
"transactions WHERE responseCode LIKE :codeQuery AND (path LIKE :pathQuery OR " +
"graphQlOperationName LIKE :graphQlQuery) ORDER BY requestDate DESC",
"graphQlOperationName LIKE :graphQlQuery OR " +
"requestContentType LIKE :contentTypeQuery) ORDER BY requestDate DESC",
)
fun getFilteredTuples(
codeQuery: String,
pathQuery: String,
graphQlQuery: String = "",
contentTypeQuery: String = "",
): LiveData<List<HttpTransactionTuple>>

@Insert
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.chuckerteam.chucker.R
import com.chuckerteam.chucker.api.BodyDecoder
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import okhttp3.MultipartBody
import okhttp3.Request
import okio.Buffer
import okio.ByteString
Expand All @@ -17,6 +18,11 @@ internal class RequestProcessor(
private val headersToRedact: Set<String>,
private val bodyDecoders: List<BodyDecoder>,
) {
private companion object {
const val MAX_PREFIX_LENGTH = 64L
const val MAX_CODEPOINTS_TO_CHECK = 16
}
Comment on lines +21 to +24
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constants MAX_PREFIX_LENGTH and MAX_CODEPOINTS_TO_CHECK differ from the existing implementation in OkioUtils.kt (which uses MAX_PREFIX_SIZE = 64L and CODE_POINT_SIZE = 16). While the values are the same, using different constant names can cause confusion. If you switch to using Buffer.isProbablyPlainText as suggested, this inconsistency would be resolved.

Copilot uses AI. Check for mistakes.

fun process(
request: Request,
transaction: HttpTransaction,
Expand Down Expand Up @@ -60,14 +66,22 @@ internal class RequestProcessor(
return
}

if (body is MultipartBody) {
val content = processMultipartPayload(body)
transaction.requestBody = content
transaction.isRequestBodyEncoded = false
return
}

val requestSource =
try {
Buffer().apply { body.writeTo(this) }
} catch (e: IOException) {
Logger.error("Failed to read request payload", e)
return
}
val limitingSource = LimitingSource(requestSource.uncompress(request.headers), maxContentLength)
val limitingSource =
LimitingSource(requestSource.uncompress(request.headers), maxContentLength)

val contentBuffer = Buffer().apply { limitingSource.use { writeAll(it) } }

Expand All @@ -79,6 +93,60 @@ internal class RequestProcessor(
}
}

private fun processMultipartPayload(body: MultipartBody): String {
return buildString {
body.parts.forEach { part ->
part.headers?.forEach { header ->
append(header.first + ": " + header.second + "\n")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you string interpolate here?

}
val partBody = part.body
if (partBody.contentType() != null) {
append("Content-Type: ${partBody.contentType()}\n")
}
if (partBody.contentLength() != -1L) {
append("Content-Length: ${partBody.contentLength()}\n")
}

val buffer = Buffer()
partBody.writeTo(buffer)
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to partBody.writeTo(buffer) could throw an IOException but is not wrapped in a try-catch block. If this fails, the entire multipart processing would fail and potentially crash. Consider adding error handling similar to how it's done in processPayload for regular request bodies (lines 76-82).

Suggested change
partBody.writeTo(buffer)
try {
partBody.writeTo(buffer)
} catch (e: IOException) {
Logger.error("Failed to read multipart request payload", e)
append(context.getString(R.string.chucker_body_content_truncated))
return@buildString
}

Copilot uses AI. Check for mistakes.

if (isPlainText(buffer)) {
append("\n")
append(buffer.readUtf8())
} else {
append("\n(binary: ${partBody.contentLength()} bytes)")
}
append("\n\n")
Comment on lines +96 to +119
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each multipart part's body is fully read into memory before checking against maxContentLength. For very large plain text parts, this could cause memory issues. Consider checking the accumulated length before reading each part, or implementing a streaming approach similar to how regular request bodies are handled with LimitingSource.

Copilot uses AI. Check for mistakes.

if (length >= maxContentLength) {
append(context.getString(R.string.chucker_body_content_truncated))
return@buildString
}
}
}
}

@Suppress("TooGenericExceptionCaught", "SwallowedException")
private fun isPlainText(buffer: Buffer): Boolean {
try {
val prefix = Buffer()
val byteCount = if (buffer.size < MAX_PREFIX_LENGTH) buffer.size else MAX_PREFIX_LENGTH
buffer.copyTo(prefix, 0, byteCount)
repeat(MAX_CODEPOINTS_TO_CHECK) {
if (prefix.exhausted()) {
return@repeat
}
val codePoint = prefix.readUtf8CodePoint()
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) {
return false
}
}
return true
} catch (e: Exception) {
return false
}
}
Comment on lines +129 to +148
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isPlainText method duplicates existing functionality. The codebase already has a Buffer.isProbablyPlainText extension property in OkioUtils.kt that does the same thing. Consider using the existing utility instead of creating a duplicate implementation. This would reduce code duplication and ensure consistent behavior across the codebase.

Copilot uses AI. Check for mistakes.

private fun decodePayload(
request: Request,
body: ByteString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,27 @@ internal class TransactionActivity : BaseChuckerActivity() {

override fun onOptionsItemSelected(item: MenuItem) =
when (item.itemId) {
R.id.share_text ->
R.id.share_text -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please send those changes in a separate PR

shareTransactionAsText { transaction ->
val encodeUrls = viewModel.encodeUrl.value!!
TransactionDetailsSharable(transaction, encodeUrls)
}
R.id.share_curl ->
}

R.id.share_curl -> {
shareTransactionAsText { transaction ->
TransactionCurlCommandSharable(transaction)
}
R.id.share_file ->
}

R.id.share_file -> {
shareTransactionAsFile(EXPORT_TXT_FILE_NAME) { transaction ->
val encodeUrls = viewModel.encodeUrl.value!!
TransactionDetailsSharable(transaction, encodeUrls)
}
R.id.share_har ->
}

R.id.share_har -> {
shareTransactionAsFile(EXPORT_HAR_FILE_NAME) { transaction ->
TransactionDetailsHarSharable(
HarUtils.harStringFromTransactions(
Expand All @@ -121,7 +127,11 @@ internal class TransactionActivity : BaseChuckerActivity() {
),
)
}
else -> super.onOptionsItemSelected(item)
}

else -> {
super.onOptionsItemSelected(item)
}
}

private fun shareTransactionAsText(block: (HttpTransaction) -> Sharable): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.chuckerteam.chucker.api

import com.chuckerteam.chucker.util.ChuckerInterceptorDelegate
import com.chuckerteam.chucker.util.ClientFactory
import com.chuckerteam.chucker.util.NoLoggerRule
import com.chuckerteam.chucker.util.readByteStringBody
import com.google.common.truth.Truth.assertThat
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Rule
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.io.File

@ExtendWith(NoLoggerRule::class)
internal class ChuckerInterceptorMultipartTest {
@get:Rule
val server = MockWebServer()

private val serverUrl = server.url("/")

@TempDir
lateinit var tempDir: File

@ParameterizedTest
@EnumSource(value = ClientFactory::class)
fun `multipart body is formatted correctly`(factory: ClientFactory) {
val chuckerInterceptor =
ChuckerInterceptorDelegate(
cacheDirectoryProvider = { tempDir },
)
val client = factory.create(chuckerInterceptor)

val binaryData = byteArrayOf(0x00, 0x01, 0x02, 0x03)
val multipartBody =
MultipartBody
.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("title", "Square Logo")
.addFormDataPart(
"image",
"logo.png",
binaryData.toRequestBody("image/png".toMediaType()),
).build()

val request =
Request
.Builder()
.url(serverUrl)
.post(multipartBody)
.build()
server.enqueue(MockResponse().setBody("OK"))

client.newCall(request).execute().readByteStringBody()

val transaction = chuckerInterceptor.expectTransaction()

// This assertion is what we WANT to see after the fix.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which fix?

// Current behavior will likely fail this.
assertThat(transaction.requestBody).contains("Content-Disposition: form-data; name=\"title\"")
assertThat(transaction.requestBody).contains("Square Logo")
assertThat(transaction.requestBody).contains("Content-Disposition: form-data; name=\"image\"")
assertThat(transaction.requestBody).contains("filename=\"logo.png\"")
assertThat(transaction.requestBody).contains("Content-Type: image/png")
// Binary content should be replaced with placeholder
assertThat(transaction.requestBody).contains("(binary: 4 bytes)")
}
Comment on lines +31 to +73
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't cover the case where multipart content exceeds maxContentLength. Consider adding a test case that validates the truncation behavior (line 121-123 in RequestProcessor.kt) works correctly for multipart bodies, similar to how it's tested for regular request bodies in ChuckerInterceptorTest.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ internal class HttpTransactionTupleTest {
error: String? = null,
graphQlOperationName: String? = null,
graphQLDetected: Boolean = false,
requestContentType: String? = null,
) = HttpTransactionTuple(
id = id,
requestDate = requestDate,
Expand All @@ -136,5 +137,6 @@ internal class HttpTransactionTupleTest {
error = error,
graphQlOperationName = graphQlOperationName,
graphQlDetected = graphQLDetected,
requestContentType = requestContentType,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ internal fun assertTuple(
assertThat(actual?.requestPayloadSize).isEqualTo(expected.requestPayloadSize)
assertThat(actual?.responsePayloadSize).isEqualTo(expected.responsePayloadSize)
assertThat(actual?.error).isEqualTo(expected.error)
assertThat(actual?.requestContentType).isEqualTo(expected.requestContentType)
}

internal fun assertTransaction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,11 @@ internal class HttpTransactionDatabaseRepositoryTest {
testObject.insertTransaction(transactionTwo)
testObject.insertTransaction(transactionThree)

testObject.getFilteredTransactionTuples(code = "", path = "def").observeForever { result ->
assertTuples(listOf(transactionThree, transactionTwo), result)
}
testObject
.getFilteredTransactionTuples(code = "", path = "def")
.observeForever { result ->
assertTuples(listOf(transactionThree, transactionTwo), result)
}
}

@Test
Expand All @@ -198,9 +200,11 @@ internal class HttpTransactionDatabaseRepositoryTest {
testObject.insertTransaction(transactionTwo)
testObject.insertTransaction(transactionThree)

testObject.getFilteredTransactionTuples(code = "4", path = "").observeForever { result ->
assertTuples(listOf(transactionThree, transactionOne), result)
}
testObject
.getFilteredTransactionTuples(code = "4", path = "")
.observeForever { result ->
assertTuples(listOf(transactionThree, transactionOne), result)
}
}

@Test
Expand Down Expand Up @@ -231,9 +235,11 @@ internal class HttpTransactionDatabaseRepositoryTest {
testObject.insertTransaction(transactionTwo)
testObject.insertTransaction(transactionThree)
testObject.insertTransaction(transactionFour)
testObject.getFilteredTransactionTuples(code = "", path = "GetDe").observeForever { result ->
assertTuples(listOf(transactionFour), result)
}
testObject
.getFilteredTransactionTuples(code = "", path = "GetDe")
.observeForever { result ->
assertTuples(listOf(transactionFour), result)
}
}

@Test
Expand Down Expand Up @@ -265,10 +271,48 @@ internal class HttpTransactionDatabaseRepositoryTest {
testObject.insertTransaction(transactionThree)
testObject.insertTransaction(transactionFour)
testObject.getFilteredTransactionTuples(code = "", path = "").observeForever { result ->
assertTuples(listOf(transactionFour, transactionThree, transactionOne, transactionTwo), result)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you send this + all the other related changes as a separate PR please?

assertTuples(
listOf(
transactionFour,
transactionThree,
transactionOne,
transactionTwo,
),
result,
)
}
}

@Test
fun `transaction tuples are filtered by requestContentType`() =
runBlocking {
val transactionOne =
createRequest("abc").withResponseData().apply {
requestDate = 200L
requestContentType = "application/json"
}
val transactionTwo =
createRequest("abcdef").withResponseData().apply {
requestDate = 100L
requestContentType = "multipart/form-data"
}
val transactionThree =
createRequest("def").withResponseData().apply {
requestDate = 300L
requestContentType = "text/plain"
}

testObject.insertTransaction(transactionOne)
testObject.insertTransaction(transactionTwo)
testObject.insertTransaction(transactionThree)

testObject
.getFilteredTransactionTuples(code = "", path = "multipart")
.observeForever { result ->
assertTuples(listOf(transactionTwo), result)
}
}

@Test
fun `delete all transactions`() =
runBlocking {
Expand Down
Loading