Skip to content

Commit 5a26dfa

Browse files
ADFA-5405: Let templates reference each other (#1779)
* ADFA-5405: Add a Pebble loader that reads the Templates table Pebble's StringLoader treats the name it is handed as the template body, so a cross-reference resolves to itself: {% include "nav.peb" %} renders the literal text "nav.peb", with no exception and no log line. That caps the web server at one self-contained template per page. This loader resolves a name against Templates.name instead, so extends, include, import and embed all work. A name with no row throws LoaderException naming it. Not wired up yet -- the next commit switches the engine over to it. * ADFA-5405: Load templates by name, so they can reference each other The engine now loads through DatabaseTemplateLoader instead of StringLoader, so an author can build a page out of several Templates rows -- a layout to extend, a nav partial to include -- instead of one self-contained file. Content rows still name their outermost template by id, so render() resolves the id to a name and lets the engine load and cache from there. That drops our own compiled-template map: the engine already caches by name, which is the better key anyway, since a partial shared by many pages is then compiled once. Both caches are dropped on a database swap, the engine's included -- it caches by name, so a template edited under the same name would otherwise survive one. A reference to a name with no row now fails the request. It used to render the name as text. * ADFA-5405: Render the bookshelf by name, dropping its id cache Templates resolve by name now, so the bookshelf endpoint no longer has to look its id up and hold on to it: renderNamedTemplate takes the name straight. That removes the whole cache-coherency mechanism the cached id needed -- the volatile field, its lock, the generation it was tagged with, and the pre-serve refresh that existed only to make the generation check land on the right side of a swap. Nothing outside the content source caches per database any more, and the source applies a pending swap inside lookup()/withDatabase() itself. isCursorOneRow went with it; the id lookup was its only caller. * ADFA-5405: Name the unresolvable template in the /pr/bs error handleBsEndpoint replaced the caught exception's message with generic text, so a bookshelf template the loader cannot resolve -- the row itself, or anything it references -- 500ed with nothing to identify it. The name is the whole point of failing loudly. The generic text stays as the fallback for an exception with no message. Found in review of #1779. * ADFA-5405: Handle a reference cycle, and clean up the render diagnostics Review follow-ups on #1779. A cycle between two templates is reachable now that a reference resolves to another row, and Pebble has no cycle detection: it recurses until the stack ends. StackOverflowError is an Error, so every catch on the serving path passes it through and the client gets a closed socket with no status line. Raised as an IllegalStateException naming the template instead. The listener already survived this (the accept loop catches Throwable), so this is about the response. PebbleException formats getMessage() as "<text> (<file>:<line>)" and the loader throws with both null, so /pr/bs was answering "... not found in the database (?:?)". Translated to getPebbleMessage() in the content source, which also keeps Pebble out of both transports. clearTemplateCache() left the tag cache populated, so a template's {% cache %} blocks would outlive the database swap the rest of the method exists to handle. Dropped renderTemplate(): the id-keyed entry point had one caller, which the previous commit moved to renderNamedTemplate, and no test uses it. Said plainly in the KDoc that generation and refreshDatabase have no production reader. The regression test from the previous commit asserted the body contained "bookshelf", which the generic fallback text does too -- it passed with or without the fix. It now asserts the loader's own sentence, and fails without it. * ADFA-5405: Correct the stale swap comment on serveRequest The call it described, discardCachesIfDatabaseChanged(), was deleted two commits ago. The swap is applied by lookup()/withDatabase() inside the content source now, so a request reaching neither does not poll for one -- the opposite of what the comment led a reader to expect. * ADFA-5405: Keep a parse error's file and line, and bound a runaway render Second review pass on #1779. The getPebbleMessage() change in 6e6d865 traded one diagnostic loss for another: it strips PebbleException's "(<file>:<line>)" suffix from every exception, not just the loader's null/null ones, so a syntax error in a template said what was wrong but not which template or line. Now conditional on the exception actually carrying neither. The new test fails without it with 'Unexpected token "EXECUTE_END"' and no template name. maxRenderedSize bounds output that grows without end -- a runaway loop stays at one frame, so the StackOverflowError guard never sees it and OutOfMemoryError is an Error every catch on this path misses. Pebble raises a PebbleException at the limit instead. Not covered by a test: exercising it means rendering 16M chars. clearTemplateCache() now takes the write lock. The sentinel and the interceptor call it with no lock, so clearing three caches piecemeal under a concurrent render could hand it a template from before the clear and a tag-cache miss from after -- the mixed state the sentinel is pressed to escape. resourceExists() no longer copies the whole template blob into a CursorWindow to answer a boolean, and generation/refreshDatabase() are marked @VisibleForTesting rather than described as unused in prose. The Templates DDL is now in documentation-database.md. Two reviews read the bare column list there and concluded name has no UNIQUE constraint; it does -- SQLite resolves the single-quoted UNIQUE('name') to the column. * ADFA-5405: Address the review, and close the leak one layer deeper All three findings hold. The first one is not fully fixed by what it suggested, which is the interesting part. The echoed message is narrowed, and there was a second site. The suggestion was to give render failures a distinct type and echo only those; TemplateRenderException does that, extending IllegalStateException so callers that only care the render failed are unaffected. But narrowing handleBsEndpoint's catch does not stop the leak: realHandleBsEndpoint has its own catch around bookshelfJson which calls sendError with e.message and returns null, so the outer catch never sees the exception. That inner site is where a SQLiteException's SQL and withDatabase's check() failure -- which names the database file -- were actually reaching the client. Both are closed now. The regression test fails against the first fix alone, which is how the second site turned up. MAX_RENDERED_CHARS drops from 16 MiB to 1 MiB. The arithmetic in the finding is right: Pebble counts characters, so 16 Mi chars is a 33.5 MB char[], the doubling that reaches it holds 33.5 MB and 67 MB at once, and toString() copies another 33.5 MB. Against a 192-256 MB heap the runaway loop OOMs long before the guard fires -- the one case it exists for. The old number was headroom over the largest context in the database, which is an unrelated quantity, as its own comment conceded. 1 MiB still sits well above any legitimate rendered page here. clearTemplateCache carries swapDatabaseIfChanged's warning. It takes the write lock, withDatabase runs its block under the read lock, and ReentrantReadWriteLock does not upgrade -- so withDatabase { clearTemplateCache() } deadlocks permanently. Latent: all four current callers are outside the read lock. The note is what keeps the next one out. Separately, and NOT fixed here: sendError echoes e.message on two general request paths too (WebServer.kt around the request-processing catch, and the DocumentationLookup.Failed branch). Same exposure, any content path rather than just /pr/bs, and pre-existing rather than introduced by this ticket -- so it wants its own change, not a quiet widening of this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gw89A3KWsPtvgPYtXYLBwr * ADFA-5405: sweep the leak to the other transport, and close the swap window Review of #1779 found the error-message leak this PR fixed for /pr/bs was left in place on serveRequest, which answers every documentation URL on the same port any app on the device can GET. Same rule applies there now: only a TemplateRenderException's message reaches the client, everything else gets generic text and the detail goes to the log. The bookshelf handler built its payload under one withDatabase and rendered the template under a second, so a debug-database swap landing between them rendered the new database's template against the old one's payload -- the pairing the bookshelfTemplateId/generation machinery this PR deleted used to keep. renderNamedTemplate now takes a payload builder and spans both under one acquisition. Nesting was not an option: withDatabase takes the write lock to check for a swap before it takes the read lock, so a nested call deadlocks. That also removes the inner try/catch around the payload build, which is why `a failure that is not a template failure answers 500 without leaking internals` could not fail before: the inner catch answered and returned, so the classification the test names never ran. It runs now, and reverting the classification fails it. DatabaseTemplateLoader: every failure leaves as a LoaderException, including SQLite's. Pebble does not wrap what a loader throws, so a raw SQLiteException escaped renderNamed's PebbleException catch carrying SQL text and reached the branch above as "not a template failure". Also rejects a duplicated name instead of taking row 0 by scan order (the sibling check templateName already made for the id path), and a NULL content column by name instead of an NPE with no message. templateName's two diagnostics are TemplateRenderException now, so the id half of the render path classifies the same way as the name half, and its database failures are wrapped rather than escaping with SQL text. MAX_RENDERED_CHARS keeps its value and loses the claim that it is "6x the largest legitimate rendered page here" -- unmeasured, and unmeasurable from this repo, since documentation.db is fetched rather than checked in. The comment now says what the number is for and what to do if a real page ever trips it. Not done, filed as ADFA-5626: replacing the StackOverflowError catch with an in-flight template-name set. It is the better mechanism, but Pebble resolves {% include %} at evaluate time against its own compiled-template cache, so the loader is not consulted and there is no seam to track names through without a custom extension. The catch does produce a correct 500 naming the template. Also not done: removing refreshDatabase() and generation as dead production code. Both are test-only, but the two refreshDatabase tests pin a real past regression (it used to fall through to the swap check after close()), and I would rather keep that coverage than the tidiness. Noted in ADFA-5626. Tests: 109 green across the documentation and web server suites, three new loader tests, one new WebServerTest for the swept sibling. Both new guards were mutated and fail without their fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j * ADFA-5405: sweep resourceExists, and widen the render cap resourceExists had none of the error handling getReader was given, so a SQLiteException escaped it raw with its SQL text -- and because it returns a Boolean rather than a Reader, the caller above could not classify the throw as a template failure at all. Same wrapping as getReader now. The KDoc's reason for not caring was that Pebble only reaches this through loaders that are not wired here, which is a fact about today's wiring rather than about the loader's contract. MAX_RENDERED_CHARS goes from 1 MiB to 4 Mi chars. The old number was uncalibrated by my own admission, and a cap where Pebble's default is unbounded can turn a page that served into a 500. 4 Mi chars is an 8 MB char[] with a ~32 MB transient through the doubling step and toString, against a 192-256 MB heap -- still comfortably ahead of the OOM it exists to catch, since unbounded growth passes any finite number, but with a wide margin over real content rather than a tight one. Review argued the multi-megabyte rows readChunks exists for prove content that large reaches the writer. They do not: render() runs only for templateId > 0 and those rows are the bundled PDFs, which have no template. The comment now says so, since that was the reasoning the old number lacked. Tests: 110 green, one new for the swept sibling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j * ADFA-5405: guard the other NULL column templateName() read cursor.getString(0) into a non-null String. getString returns a platform type, so a NULL Templates.name yielded null and the implicit check threw a bare NPE, which the RuntimeException catch below rewrapped as "Cannot read the template for ID N" -- losing which column was null and, unlike the loader's path, never naming the template. The loader guards exactly this for getBlob, with a test. This was the second of the two sites and the sweep missed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j * ADFA-5405: honour the loader's existence contract, and stop encoding one fact twice resourceExists is Pebble's existence predicate -- what a DelegatingLoader uses to decide whether to fall through to the next loader -- so a throw there aborts resolution where a miss would fall back. The previous commit made it throw, to keep SQL text out of the response. That kept the SQL out and broke the contract to do it; logging keeps both. It also disagreed with getReader about a duplicated name: the predicate said the template existed, the reader refused to load it. It counts now rather than existence-checking, so a name with more than one row answers false, which is what the reader will do with it anyway. realHandleBsEndpoint returns Unit. Removing the isCursorOneRow path took its only `return false` with it, so the Boolean had become a constant that the caller assigned to the same variable markOutputStarted already set -- two mechanisms for one piece of state, and a later early return that updated only one would leave the caller sending response headers onto a socket that already carries a body. Tests: 113 green across the documentation and web server suites, two new for the predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 424e8d8 commit 5a26dfa

7 files changed

Lines changed: 762 additions & 179 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt‎

Lines changed: 42 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.itsaky.androidide.localWebServer
22

3-
import android.database.Cursor
43
import android.database.sqlite.SQLiteDatabase
54
import android.net.TrafficStats
65
import android.os.Environment.getExternalStorageDirectory
@@ -11,6 +10,7 @@ import com.itsaky.androidide.documentation.DocumentationContent
1110
import com.itsaky.androidide.documentation.DocumentationContentSource
1211
import com.itsaky.androidide.documentation.DocumentationLookup
1312
import com.itsaky.androidide.documentation.DocumentationRequestInterceptor
13+
import com.itsaky.androidide.documentation.TemplateRenderException
1414
import com.itsaky.androidide.utils.ContentTypeHeaders
1515
import com.itsaky.androidide.utils.DatabaseVersionResolver
1616
import org.slf4j.LoggerFactory
@@ -148,18 +148,6 @@ class WebServer(
148148
.serializeNulls()
149149
.create()
150150

151-
// -1 means "not fetched yet". Volatile because the WebView transport shares this server's
152-
// process, and the interceptor's reads can run on WebView threads while the accept loop writes.
153-
@Volatile
154-
private var bookshelfTemplateId: Int = -1
155-
156-
private val cacheLock = Any()
157-
158-
// Which of the source's databases bookshelfTemplateId was filled from. The compiled templates
159-
// themselves live in the source and are dropped by its own swap.
160-
@Volatile
161-
private var cachedDatabaseGeneration = 0L
162-
163151
// Long enough to stop a descriptor-exhaustion spin starving the connections whose closing would
164152
// fix it; short enough to be invisible to a user, and never paid on a successful accept.
165153
private val initialAcceptBackoffMs = 50L
@@ -547,31 +535,11 @@ class WebServer(
547535
return sendError(writer, output, 501, "Not Implemented")
548536
}
549537

550-
// serveRequest applies any pending sdcard debug-database swap via the content source.
538+
// The content source applies a pending sdcard debug-database swap inside lookup()/withDatabase(),
539+
// so a request reaching neither -- an unknown /pr/ target -- does not poll for one.
551540
serveRequest(writer, output, path)
552541
}
553542

554-
/**
555-
* Invalidates the cached bookshelf template identifier when the documentation database changes.
556-
*/
557-
private fun discardCachesIfDatabaseChanged() {
558-
// Apply any pending swap first. The source swaps inside lookup()/withDatabase(), so checking
559-
// the generation before those runs reads the generation from before the swap: on the very
560-
// request that swaps, this would leave bookshelfTemplateId pointing at the previous
561-
// database's template row -- rendering the old bookshelf, or 500ing if that id is absent.
562-
contentSource.refreshDatabase()
563-
564-
if (contentSource.generation == cachedDatabaseGeneration) return
565-
566-
synchronized(cacheLock) {
567-
val generation = contentSource.generation
568-
if (generation == cachedDatabaseGeneration) return
569-
570-
bookshelfTemplateId = -1
571-
cachedDatabaseGeneration = generation
572-
}
573-
}
574-
575543
/**
576544
* Serves a parsed request using the appropriate diagnostic endpoint or documentation content.
577545
*
@@ -584,8 +552,6 @@ class WebServer(
584552
output: java.io.OutputStream,
585553
path: String,
586554
) {
587-
discardCachesIfDatabaseChanged()
588-
589555
// Handle the special "pr" endpoint with highest priority
590556
if (path.startsWith("pr/", false)) {
591557
if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path)
@@ -623,7 +589,13 @@ class WebServer(
623589
}
624590

625591
is DocumentationLookup.Failed -> {
626-
sendError(writer, output, httpInternalServerError, "Internal Server Error", lookup.cause.message ?: "")
592+
log.error("Cannot serve the documentation request", lookup.cause)
593+
// Same rule as /pr/bs, and for the same reason: only a template failure names a
594+
// template, and only its message is safe to send. A SQLiteException carries SQL text
595+
// and withDatabase's check() carries the database's filesystem path, and any app on
596+
// the device can GET this port. This is the sibling the first pass missed.
597+
val detail = (lookup.cause as? TemplateRenderException)?.message ?: "Internal Server Error"
598+
sendError(writer, output, httpInternalServerError, "Internal Server Error", detail)
627599
}
628600
}
629601
}
@@ -801,10 +773,25 @@ class WebServer(
801773
var outputStarted = false
802774

803775
try {
804-
outputStarted = realHandleBsEndpoint(writer, output) { outputStarted = true }
776+
realHandleBsEndpoint(writer, output) { outputStarted = true }
805777
} catch (e: Exception) {
806778
log.error("Error handling /pr/bs endpoint: {}", e.message)
807-
sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted)
779+
// The message is echoed ONLY for a template failure. That one names a template -- the
780+
// bookshelf row itself, or anything it references -- and the name is the whole diagnostic
781+
// (ADFA-5405). Everything else keeps the generic text, because this catch spans the whole
782+
// of realHandleBsEndpoint: a SQLiteException carries SQL, and withDatabase's
783+
// check(openIfNeeded()) carries the database's filesystem path. Any app on the device can
784+
// GET this port, so echoing those was handing out internals for the sake of one
785+
// diagnostic.
786+
val detail = (e as? TemplateRenderException)?.message ?: "Error generating bookshelf HTML."
787+
sendError(
788+
writer,
789+
output,
790+
httpInternalServerError,
791+
"Internal Server Error 6",
792+
detail,
793+
outputStarted,
794+
)
808795
}
809796

810797
if (debugEnabled) log.debug("Leaving handleBsEndpoint().")
@@ -864,54 +851,36 @@ class WebServer(
864851
/**
865852
* Generates the bookshelf page and sends it to the client.
866853
*
867-
* @return `true` if a response was produced, `false` if processing failed or no response was produced.
854+
* Returns nothing: [markOutputStarted] is how the caller learns the response has begun, and it
855+
* fires at the moment it actually does. Returning the same fact as well meant two mechanisms
856+
* for one piece of state -- and once the only early return went, the returned value was a
857+
* constant. A later early return that updated one and not the other would leave the caller
858+
* sending response headers onto a socket that already carries a body.
868859
*/
869860
private fun realHandleBsEndpoint(
870861
writer: PrintWriter,
871862
output: java.io.OutputStream,
872863
markOutputStarted: () -> Unit,
873-
): Boolean {
864+
) {
874865
if (debugEnabled) log.debug("Entering realHandleBsEndpoint().")
875866

876-
// Null means an error response has already been sent, so there is nothing left to write.
877-
val jsonText =
878-
contentSource.withDatabase { database ->
879-
try {
880-
val json = bookshelfJson(database)
881-
if (debugEnabled) log.debug("json content = '{}'.", String(json, Charsets.UTF_8))
882-
if (debugEnabled) log.debug("before fetch bookshelf template ID = '{}'", bookshelfTemplateId)
883-
884-
// Have we already fetched the template
885-
if (bookshelfTemplateId == -1) {
886-
database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()).use { cursor ->
887-
if (!isCursorOneRow(cursor, writer, output)) {
888-
return@withDatabase null
889-
}
890-
891-
cursor.moveToFirst()
892-
bookshelfTemplateId = cursor.getInt(0)
893-
if (debugEnabled) log.debug("after the fetch bookshelf template ID = '{}'", bookshelfTemplateId)
894-
}
895-
}
896-
897-
json
898-
} catch (e: Exception) {
899-
log.error("Error processing request: {}", e.message)
900-
sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "")
901-
null
867+
// The payload and the template are built under one database acquisition, so a swap cannot
868+
// land between them. Nothing is caught here: handleBsEndpoint's catch is the single place
869+
// that decides what reaches the client, and an inner catch that answered and returned made
870+
// that decision unreachable for everything raised inside this block.
871+
val result =
872+
contentSource.renderNamedTemplate("bookshelf", "/bookshelf") { database ->
873+
bookshelfJson(database).also {
874+
if (debugEnabled) log.debug("json content = '{}'.", String(it, Charsets.UTF_8))
902875
}
903-
} ?: return false
904-
905-
val result = contentSource.renderTemplate(bookshelfTemplateId, jsonText, "/bookshelf")
876+
}
906877

907878
if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result))
908879

909880
markOutputStarted()
910881
writeNormalToClient(writer, output, String(result))
911882

912883
if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().")
913-
914-
return true
915884
}
916885

917886
/**
@@ -1070,22 +1039,6 @@ ORDER BY BC.category,
10701039
)
10711040
}
10721041

1073-
private fun isCursorOneRow(
1074-
cursor: Cursor,
1075-
writer: PrintWriter,
1076-
output: java.io.OutputStream,
1077-
): Boolean {
1078-
if (cursor.count == 1) {
1079-
return true
1080-
}
1081-
if (cursor.count == 0) {
1082-
sendError(writer, output, httpNotFound, "Corrupt database, no rows found, expected one.")
1083-
} else {
1084-
sendError(writer, output, httpInternalServerError, "Corrupt database - found ${cursor.count} rows when 1 was expected.")
1085-
}
1086-
return false
1087-
}
1088-
10891042
/**
10901043
* Builds an HTML table of recent projects from the provided project database and writes it to the client.
10911044
*

‎app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt‎

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -363,15 +363,9 @@ class WebServerTest {
363363
// The bookshelf join matches nothing: a cursor whose moveToNext() is immediately false.
364364
every { db.rawQuery(match { it.contains("FROM Content AS C") }, any()) } returns
365365
mockk<Cursor>(relaxed = true) { every { moveToNext() } returns false }
366-
// The bookshelf template: its id lookup, then its body -- a Pebble expression over the JSON
367-
// context, so the assertion proves the empty-shelf payload actually reached the render.
366+
// The bookshelf template's body, fetched by name (ADFA-5405) -- a Pebble expression over the
367+
// JSON context, so the assertion proves the empty-shelf payload actually reached the render.
368368
every { db.rawQuery(match { it.contains("FROM Templates WHERE name") }, any()) } returns
369-
mockk<Cursor>(relaxed = true) {
370-
every { count } returns 1
371-
every { moveToFirst() } returns true
372-
every { getInt(0) } returns 7
373-
}
374-
every { db.rawQuery(match { it.contains("FROM Templates WHERE id") }, any()) } returns
375369
mockk<Cursor>(relaxed = true) {
376370
every { count } returns 1
377371
every { moveToFirst() } returns true
@@ -392,6 +386,114 @@ class WebServerTest {
392386
}
393387
}
394388

389+
// ADFA-5405: a template the endpoint cannot resolve -- the bookshelf row, or anything it
390+
// references -- is named by the loader's throw, and handleBsEndpoint has to pass that name on
391+
// rather than replace it with its generic text. The name is the whole diagnostic.
392+
@Test
393+
fun `a bookshelf template that is not in the database answers 500 naming it`() {
394+
val port = freePort()
395+
val db = mockk<SQLiteDatabase>(relaxed = true)
396+
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db
397+
every { db.rawQuery(match { it.contains("FROM Content AS C") }, any()) } returns
398+
mockk<Cursor>(relaxed = true) { every { moveToNext() } returns false }
399+
// No bookshelf row: a relaxed cursor's moveToFirst() is already false.
400+
every { db.rawQuery(match { it.contains("FROM Templates WHERE name") }, any()) } returns mockk<Cursor>(relaxed = true)
401+
402+
val server = WebServer(testConfig(port))
403+
val serverThread = Thread { server.start() }.apply { isDaemon = true }
404+
serverThread.start()
405+
try {
406+
awaitPortBound(port)
407+
val response = sendRawGetRequest(port, "/pr/bs")
408+
assertTrue("Expected a 500 status line, got:\n$response", response.startsWith("HTTP/1.1 500"))
409+
// The loader's own text, not just the template name: the generic fallback on the same
410+
// sendError call is "Error generating bookshelf HTML.", which contains "bookshelf" too,
411+
// so asserting on the name alone passes with or without the fix.
412+
assertTrue(
413+
"Expected the loader's diagnostic, got:\n$response",
414+
response.contains("Template 'bookshelf' not found in the database"),
415+
)
416+
assertFalse("Expected no Pebble placeholder padding, got:\n$response", response.contains("(?:?)"))
417+
} finally {
418+
server.stop()
419+
serverThread.join(2_000)
420+
}
421+
}
422+
423+
// The other half of the ADFA-5405 diagnostic: handleBsEndpoint's catch spans the whole handler,
424+
// so echoing e.message put anything thrown in there into the response body -- a SQLiteException's
425+
// SQL, or withDatabase's check() failure naming the database file. Any app on the device can GET
426+
// this port. Only a template failure is echoed now; this pins that the rest is not.
427+
//
428+
// This throw has to reach that catch to pin anything. It did not until the inner try/catch
429+
// around the payload build was removed: that one answered and returned, so the classification
430+
// under test never ran and this test passed against the unfixed code.
431+
@Test
432+
fun `a failure that is not a template failure answers 500 without leaking internals`() {
433+
val port = freePort()
434+
val db = mockk<SQLiteDatabase>(relaxed = true)
435+
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db
436+
// Thrown from inside the handler rather than from openDatabase, which would fail start()
437+
// before the port is bound. The type matters more than the origin: this is the same
438+
// IllegalStateException that withDatabase's check() raises, which used to be
439+
// indistinguishable from a template diagnostic and so was echoed verbatim -- and its message
440+
// carries both a filesystem path and SQL, the two things worth not sending.
441+
every { db.rawQuery(match { it.contains("FROM Content AS C") }, any()) } throws
442+
IllegalStateException(
443+
"unable to open database file /data/user/0/com.itsaky.androidide/databases/documentation.db " +
444+
"(while compiling: SELECT C.content FROM Content AS C JOIN ContentTypes)",
445+
)
446+
447+
val server = WebServer(testConfig(port))
448+
val serverThread = Thread { server.start() }.apply { isDaemon = true }
449+
serverThread.start()
450+
try {
451+
awaitPortBound(port)
452+
val response = sendRawGetRequest(port, "/pr/bs")
453+
assertTrue("Expected a 500 status line, got:\n$response", response.startsWith("HTTP/1.1 500"))
454+
assertTrue(
455+
"Expected the generic text, got:\n$response",
456+
response.contains("Error generating bookshelf HTML."),
457+
)
458+
assertFalse("Leaked a filesystem path:\n$response", response.contains("/data/user/0/"))
459+
assertFalse("Leaked a database filename:\n$response", response.contains("documentation.db"))
460+
assertFalse("Leaked SQL text:\n$response", response.contains("SELECT C.content"))
461+
} finally {
462+
server.stop()
463+
serverThread.join(2_000)
464+
}
465+
}
466+
467+
// The sibling handleBsEndpoint's fix missed: serveRequest answers every documentation URL, and
468+
// its Failed branch sent lookup.cause.message verbatim. Same port, same reachable-by-any-app
469+
// exposure, and ADFA-5405's loader made database failures reachable from more places.
470+
@Test
471+
fun `a documentation request that fails answers 500 without leaking internals`() {
472+
val port = freePort()
473+
val db = mockk<SQLiteDatabase>(relaxed = true)
474+
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db
475+
every { db.rawQuery(match { it.contains("FROM Content") }, any()) } throws
476+
IllegalStateException(
477+
"unable to open database file /data/user/0/com.itsaky.androidide/databases/documentation.db " +
478+
"(while compiling: SELECT C.content FROM Content C)",
479+
)
480+
481+
val server = WebServer(testConfig(port))
482+
val serverThread = Thread { server.start() }.apply { isDaemon = true }
483+
serverThread.start()
484+
try {
485+
awaitPortBound(port)
486+
val response = sendRawGetRequest(port, "/k/html/basic-syntax.html")
487+
assertTrue("Expected a 500 status line, got:\n$response", response.startsWith("HTTP/1.1 500"))
488+
assertFalse("Leaked a filesystem path:\n$response", response.contains("/data/user/0/"))
489+
assertFalse("Leaked a database filename:\n$response", response.contains("documentation.db"))
490+
assertFalse("Leaked SQL text:\n$response", response.contains("SELECT C.content"))
491+
} finally {
492+
server.stop()
493+
serverThread.join(2_000)
494+
}
495+
}
496+
395497
// ADFA-5241: the two transports have to answer the same way about what a response says, and
396498
// only a real response proves what this one sends. The decision itself lives in
397499
// ContentTypeHeaders, shared with DocumentationRequestInterceptor.

0 commit comments

Comments
 (0)