diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index 583d1a9a..a56f186b 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -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 }, ) { diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 461f3b82..2bd1ee56 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -53,9 +53,13 @@ pub(crate) async fn handle_plugin_event( .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, diff --git a/crates/yaak/src/plugin_events.rs b/crates/yaak/src/plugin_events.rs index ef367ada..2930cdb2 100644 --- a/crates/yaak/src/plugin_events.rs +++ b/crates/yaak/src/plugin_events.rs @@ -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, ) diff --git a/crates/yaak/src/response_body.rs b/crates/yaak/src/response_body.rs index f0d7945f..c6aa83a2 100644 --- a/crates/yaak/src/response_body.rs +++ b/crates/yaak/src/response_body.rs @@ -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> { - 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> { + 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 { + 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 { - 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"); + } } } diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index 541482f1..ca1afdd1 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -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 { + // 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 });