Read bodies of responses the engine never recorded

A send with no request behind it — a plugin's ad-hoc ctx.httpRequest.send,
GraphQL introspection — gets a generated id and a body file, but no row.
Resolving purely through the database refused those, which would have
broken auth-oauth2 the moment it moved off readFileSync, since every
request it sends is ad-hoc.

The store now falls back to the response directory when there is no row,
accepting only ids shaped the way the engine generates them. Such a
response has no stored headers, so contentType is null and text()
decodes as UTF-8 — which is what the filesystem readers did anyway.
This commit is contained in:
Gregory Schier
2026-08-16 09:35:03 -07:00
parent 96c8a95094
commit 8b031db685
5 changed files with 89 additions and 26 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ async fn build_plugin_reply(
match handle_shared_plugin_event(
&host_context.query_manager,
&FileResponseBodyStore::new(&host_context.query_manager),
&FileResponseBodyStore::new(&host_context.query_manager, &host_context.response_dir),
&event.payload,
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
) {
@@ -53,9 +53,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
.and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))
});
// Same directory the engine writes bodies into, so responses it never
// recorded are still readable by id.
let response_dir = app_handle.path().app_data_dir()?.join("responses");
match handle_shared_plugin_event(
app_handle.db_manager().inner(),
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
&FileResponseBodyStore::new(app_handle.db_manager().inner(), &response_dir),
&event.payload,
SharedPluginEventContext {
plugin_name: &plugin_name,
+2 -1
View File
@@ -481,6 +481,7 @@ mod tests {
use super::*;
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
use std::cell::RefCell;
use std::path::Path;
use tempfile::TempDir;
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
@@ -493,7 +494,7 @@ mod tests {
) -> GroupedPluginEvent<'a> {
handle_shared_plugin_event(
query_manager,
&FileResponseBodyStore::new(query_manager),
&FileResponseBodyStore::new(query_manager, Path::new("/nonexistent-response-dir")),
payload,
context,
)
+75 -20
View File
@@ -8,6 +8,7 @@
use crate::error::Result;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use yaak_models::query_manager::QueryManager;
/// The most bytes one read will hand back, however much was asked for.
@@ -43,34 +44,60 @@ pub trait ResponseBodyStore {
/// filesystem holds it.
pub struct FileResponseBodyStore<'a> {
query_manager: &'a QueryManager,
response_dir: &'a Path,
}
impl<'a> FileResponseBodyStore<'a> {
pub fn new(query_manager: &'a QueryManager) -> Self {
Self { query_manager }
pub fn new(query_manager: &'a QueryManager, response_dir: &'a Path) -> Self {
Self { query_manager, response_dir }
}
/// The file backing a response, or `None` when the response stored no body.
/// The file backing a response, or `None` when it stored no body.
///
/// A response that was never persisted (GraphQL introspection and other
/// ephemeral sends) has no row here at all, so it fails as "not found"
/// rather than reading as an empty body.
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
/// Most responses have a row that names their file. A response sent with no
/// request behind it — the plugin `ctx.httpRequest.send` of an ad-hoc
/// request, GraphQL introspection — is ephemeral: the engine gives it an id
/// and writes its body under that id, but never records it. Those bodies are
/// still the caller's to read, so fall back to where the engine puts them.
fn body_path(&self, response_id: &str) -> Result<Option<PathBuf>> {
match self.query_manager.connect().get_http_response(response_id) {
Ok(response) => Ok(response.body_path.map(PathBuf::from)),
Err(err) => match self.ephemeral_path(response_id) {
Some(path) if path.is_file() => Ok(Some(path)),
_ => Err(err.into()),
},
}
}
/// Where an unrecorded response's body would be, if the id can name one.
///
/// Ids arrive from a plugin, so only the shape the engine actually generates
/// is accepted; that leaves nothing that could climb out of the response
/// directory.
fn ephemeral_path(&self, response_id: &str) -> Option<PathBuf> {
let looks_generated = response_id.starts_with("rs_")
&& response_id.len() > 3
&& response_id[3..].chars().all(|c| c.is_ascii_alphanumeric());
looks_generated.then(|| self.response_dir.join(response_id))
}
}
impl ResponseBodyStore for FileResponseBodyStore<'_> {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
let response = self.query_manager.connect().get_http_response(response_id)?;
// The headers live on the row, so an ephemeral response has none to
// give. Readers that need its charset have the response object the send
// handed back.
let content_type = match self.query_manager.connect().get_http_response(response_id) {
Ok(response) => response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone()),
Err(_) => None,
};
let content_type = response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone());
let content_length = match response.body_path {
let content_length = match self.body_path(response_id)? {
Some(path) => std::fs::metadata(path)?.len(),
None => 0,
};
@@ -105,6 +132,10 @@ mod tests {
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
use yaak_models::util::UpdateSource;
fn store<'a>(qm: &'a QueryManager, dir: &'a TempDir) -> FileResponseBodyStore<'a> {
FileResponseBodyStore::new(qm, dir.path())
}
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
let temp_dir = TempDir::new().unwrap();
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
@@ -165,7 +196,7 @@ mod tests {
#[test]
fn info_reports_stored_size_and_content_type() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
let info = store(&qm, &_tmp).info(&id).unwrap();
assert_eq!(info.content_length, 11);
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
}
@@ -173,7 +204,7 @@ mod tests {
#[test]
fn chunks_cover_the_body_and_stop_short_at_the_end() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let store = FileResponseBodyStore::new(&qm);
let store = store(&qm, &_tmp);
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
@@ -184,7 +215,7 @@ mod tests {
#[test]
fn a_response_with_no_body_is_empty_not_an_error() {
let (qm, _tmp, id) = seed(None);
let store = FileResponseBodyStore::new(&qm);
let store = store(&qm, &_tmp);
assert_eq!(store.info(&id).unwrap().content_length, 0);
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
}
@@ -192,6 +223,30 @@ mod tests {
#[test]
fn an_unknown_response_fails() {
let (qm, _tmp, _id) = seed(Some(b"hi"));
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
assert!(store(&qm, &_tmp).info("rs_nope").is_err());
}
#[test]
fn an_ephemeral_body_is_readable_by_id_without_a_row() {
// What a plugin's ad-hoc `ctx.httpRequest.send` leaves behind: a file
// named for a response the engine never recorded.
let (qm, tmp, _id) = seed(Some(b"hi"));
std::fs::write(tmp.path().join("rs_ephemeral1"), b"access_token=abc").unwrap();
let info = store(&qm, &tmp).info("rs_ephemeral1").unwrap();
assert_eq!(info.content_length, 16);
// No row means no headers to report a charset from.
assert_eq!(info.content_type, None);
assert_eq!(store(&qm, &tmp).read_chunk("rs_ephemeral1", 0, 6).unwrap(), b"access");
}
#[test]
fn an_id_that_is_not_a_generated_one_cannot_name_a_file() {
let (qm, tmp, _id) = seed(Some(b"hi"));
std::fs::write(tmp.path().join("secrets"), b"nope").unwrap();
for id in ["secrets", "../secrets", "rs_../secrets", "rs_", "/etc/hosts"] {
assert!(store(&qm, &tmp).info(id).is_err(), "{id} should not resolve");
}
}
}
@@ -261,11 +261,14 @@ export const plugin: PluginDefinition = {
* The response's body as text, or null when there is nothing to read.
*
* The host is asked for it by response id, so this works wherever the bytes
* happen to live. A body over the runtime's size limit throws rather than
* coming back empty, since a template silently rendering to nothing is worse
* than one that says why.
* happen to live — including responses it never recorded, which still get an
* id. A body over the runtime's size limit throws rather than coming back
* empty, since a template silently rendering to nothing is worse than one that
* says why.
*/
async function readResponseBody(ctx: Context, response: HttpResponse): Promise<string | null> {
// Belt and braces: everything reaching here came from find() or send() and so
// has an id. An empty one would just be an unreadable id.
if (!response.id) return null;
const body = await ctx.httpResponse.body({ responseId: response.id });