mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 17:12:13 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user