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 @@ -10,6 +10,7 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.ToNumberPolicy
import com.google.gson.reflect.TypeToken
import com.itsaky.androidide.utils.ContentTypeHeaders
import com.itsaky.androidide.utils.DatabaseVersionResolver
import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.loader.StringLoader
Expand Down Expand Up @@ -480,6 +481,10 @@ class WebServer(
}

private fun handleClient(clientSocket: Socket) {
// Whether the client has already been told 200. Read by the error path, which must not put a
// second status line on a response that has already started (see the assignment below).
var responseStarted = false

if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket)

val input = clientSocket.getInputStream()
Expand Down Expand Up @@ -625,6 +630,8 @@ class WebServer(
while (nextChunk.size == contentChunkSize) {
val path2 = "$path-$fragmentNumber"
val cursor2 = database.rawQuery(query2, arrayOf(path2))
// Whether the client has already been told 200; see the assignment below.
var responseStarted = false
try {
if (cursor2.moveToFirst()) {
nextChunk = cursor2.getBlob(0)
Expand Down Expand Up @@ -657,17 +664,35 @@ class WebServer(
dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression)
}

// Built before the status line goes out: everything after the first println is on the
// wire (the writer autoflushes), so a throw past that point cannot be answered with a
// fresh error response. dbMimeType is a platform type from Cursor.getString, so a NULL
// ContentTypes.value throws here rather than there.
val contentTypeHeader = ContentTypeHeaders.headerValue(dbMimeType)

writer.println("HTTP/1.1 200 OK")
writer.println("Content-Type: $dbMimeType")
// From here on the client has been told 200. A failure while writing the body -- a
// dropped connection is the common one -- must not append a second status line to that
// response; sendError writes nothing at all when told the output has started, which is
// the only honest thing left to do with a half-sent reply.
responseStarted = true
writer.println("Content-Type: $contentTypeHeader")
writer.println("Content-Length: ${dbContent.size}")
writer.println("Connection: close")
writer.println()
writer.flush()
output.write(dbContent)
output.flush()
} catch (e: Exception) {
log.error("Error processing request: {}", e.message)
sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "")
log.error("Error processing request: {}", e.message, e)
sendError(
writer,
output,
httpInternalServerError,
"Internal Server Error",
e.message ?: "",
outputStarted = responseStarted,
)
} finally {
cursor.close()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
Expand Down Expand Up @@ -377,6 +378,68 @@ class WebServerTest {
}
}

// ADFA-5241: the helper decides the charset, but only a real response proves the header that
// reaches a client. Two thirds of the database's text rows are non-ASCII with no BOM, so an
// undeclared encoding renders them as mojibake in any client that does not assume UTF-8.
@Test
fun `a text response declares utf-8 and a binary one does not`() {
assertContentTypeHeader(storedMimeType = "text/html", expected = "text/html; charset=utf-8")
assertContentTypeHeader(storedMimeType = "image/png", expected = "image/png")
}

private fun assertContentTypeHeader(
storedMimeType: String,
expected: String,
) {
val port = freePort()
val db = mockk<SQLiteDatabase>(relaxed = true)
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db
// Deliberately no version stub: this row is compression = "none", so nothing decodes and no
// dictionary is consulted. Declaring one would couple the assertion to a lazy-load path it
// does not exercise.
every {
db.rawQuery(match { it.contains("FROM Content") }, any())
} returns
mockk<Cursor>(relaxed = true) {
every { count } returns 1
every { moveToFirst() } returns true
every { getBlob(0) } returns "payload".toByteArray()
every { getString(1) } returns storedMimeType
every { getString(2) } returns "none"
every { getInt(3) } returns 0
}

val server = WebServer(testConfig(port))
val serverThread = Thread { server.start() }.apply { isDaemon = true }
serverThread.start()
try {
awaitPortBound(port)
val response = sendRawGetRequest(port, "/some/path")
val header =
response.lineSequence().firstOrNull { it.startsWith("Content-Type:", ignoreCase = true) }
?: error("No Content-Type in the response:\n$response")
assertEquals("Content-Type: $expected", header.trim())
} finally {
server.stop()
serverThread.join(2_000)
}
}

// Same as sendRawGetRequestAndAwaitClose, but hands back what the server actually wrote.
private fun sendRawGetRequest(
port: Int,
path: String,
): String =
Socket().use { socket ->
socket.connect(InetSocketAddress("localhost", port), 2_000)
socket.soTimeout = 2_000
socket.getOutputStream().apply {
write("GET $path HTTP/1.1\r\n\r\n".toByteArray(Charsets.ISO_8859_1))
flush()
}
socket.getInputStream().readBytes().toString(Charsets.ISO_8859_1)
}

// Blocks until the server closes the connection (every response sends "Connection: close"),
// so by the time this returns the server has fully finished processing this one request --
// making repeated calls a reliable way to serialize several full request/response cycles.
Expand Down
Loading
Loading