diff --git a/CHANGELOG.md b/CHANGELOG.md index bd431072..19789a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,19 @@ Use `cargo release` to create a new release. - mdcat now fills paragraph text to the column limit, i.e. fills up short lines and wraps long lines (see [GH-4]). - mdcat now allows to control color and style via a new `theme` field in `mdcat::Settings` of type `mdcat::Theme` (see [GH-48]). `mdcat::Theme::default()` provides the standard mdcat 1.x colors and style. +- mdcat now exposes resource handling via the new `mdcat::resources::ResourceUrlHandler` trait (see [GH-247]). ### Changed - Update all dependencies. - `mdcat::Settings` now holds a reference to a syntax set, so the syntax set can now be shared among multiple different settings. - -### Changed - Explicitly set minimum rust version in `Cargo.toml`, and document MSRV policy. +### Removed +- `mdcat::Settings.resource_access` and the corresponding `ResourceAccess` enum (see [GH-247]). + [GH-4]: https://github.com/swsnr/mdcat/issues/4 [GH-48]: https://github.com/swsnr/mdcat/issues/48 +[GH-247]: https://github.com/swsnr/mdcat/issues/247 ## [1.1.1] – 2023-03-18 diff --git a/src/bin/mdcat/main.rs b/src/bin/mdcat/main.rs index dc2bf4a9..8dda4aeb 100644 --- a/src/bin/mdcat/main.rs +++ b/src/bin/mdcat/main.rs @@ -14,6 +14,11 @@ use std::io::Result; use std::io::{stdin, BufWriter}; use std::path::PathBuf; +use mdcat::resources::DispatchingResourceHandler; +use mdcat::resources::FileResourceHandler; +use mdcat::resources::HttpResourceHandler; +use mdcat::resources::ResourceUrlHandler; +use mdcat::resources::DEFAULT_RESOURCE_READ_LIMIT; use mdcat::Theme; use pulldown_cmark::{Options, Parser}; use syntect::parsing::SyntaxSet; @@ -23,7 +28,6 @@ use tracing_subscriber::EnvFilter; use crate::output::Output; use mdcat::terminal::{TerminalProgram, TerminalSize}; -use mdcat::ResourceAccess; use mdcat::{Environment, Settings}; mod args; @@ -52,8 +56,13 @@ fn read_input>(filename: T) -> Result<(PathBuf, String)> { } } -#[instrument(skip(output, settings), level = "debug")] -fn process_file(filename: &str, settings: &Settings, output: &mut Output) -> Result<()> { +#[instrument(skip(output, settings, resource_handler), level = "debug")] +fn process_file( + filename: &str, + settings: &Settings, + resource_handler: &dyn ResourceUrlHandler, + output: &mut Output, +) -> Result<()> { let (base_dir, input) = read_input(filename)?; event!( Level::TRACE, @@ -67,7 +76,7 @@ fn process_file(filename: &str, settings: &Settings, output: &mut Output) -> Res let env = Environment::for_local_directory(&base_dir)?; let mut sink = BufWriter::new(output.writer()); - mdcat::push_tty(settings, &env, &mut sink, parser) + mdcat::push_tty(settings, &env, resource_handler, &mut sink, parser) .and_then(|_| { event!(Level::TRACE, "Finished rendering, flushing output"); sink.flush() @@ -126,26 +135,42 @@ fn main() { let settings = Settings { terminal_capabilities: terminal.capabilities(), terminal_size: TerminalSize { columns, ..size }, - resource_access: if args.local_only { - ResourceAccess::LocalOnly - } else { - ResourceAccess::RemoteAllowed - }, syntax_set: &SyntaxSet::load_defaults_newlines(), theme: Theme::default(), }; + let mut resource_handlers: Vec> = vec![Box::new( + FileResourceHandler::new(DEFAULT_RESOURCE_READ_LIMIT), + )]; + if !args.local_only { + let user_agent = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + event!( + target: "mdcat::main", + Level::DEBUG, + "Remote resource access permitted, creating HTTP client with user agent {}", + user_agent + ); + resource_handlers.push(Box::new( + HttpResourceHandler::with_user_agent( + DEFAULT_RESOURCE_READ_LIMIT, + user_agent, + ) + // TODO: Properly return this error? + .unwrap(), + )); + } + let resource_handler = DispatchingResourceHandler::new(resource_handlers); event!( target: "mdcat::main", Level::TRACE, ?settings.terminal_size, ?settings.terminal_capabilities, - ?settings.resource_access, "settings" ); args.filenames .iter() .try_fold(0, |code, filename| { - process_file(filename, &settings, &mut output) + process_file(filename, &settings, &resource_handler, &mut output) .map(|_| code) .or_else(|error| { eprintln!("Error: {filename}: {error}"); diff --git a/src/lib.rs b/src/lib.rs index 34a1d44c..c6f8255e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,14 +20,15 @@ use syntect::parsing::SyntaxSet; use tracing::instrument; use url::Url; -// Expose some select things for use in main -pub use crate::resources::ResourceAccess; +use crate::resources::ResourceUrlHandler; use crate::terminal::capabilities::TerminalCapabilities; use crate::terminal::TerminalSize; + +// Expose some select things for use in main pub use crate::theme::Theme; mod references; -mod resources; +pub mod resources; mod svg; pub mod terminal; mod theme; @@ -46,8 +47,6 @@ pub struct Settings<'a> { pub terminal_capabilities: TerminalCapabilities, /// The size of the terminal mdcat writes to. pub terminal_size: TerminalSize, - /// Whether remote resource access is permitted. - pub resource_access: ResourceAccess, /// Syntax set for syntax highlighting of code blocks. pub syntax_set: &'a SyntaxSet, /// Colour theme for mdcat @@ -112,6 +111,7 @@ impl Environment { pub fn push_tty<'a, 'e, W, I>( settings: &Settings, environment: &Environment, + resource_handler: &dyn ResourceUrlHandler, writer: &'a mut W, mut events: I, ) -> Result<()> @@ -123,7 +123,15 @@ where let StateAndData(final_state, final_data) = events.try_fold( StateAndData(State::default(), StateData::default()), |StateAndData(state, data), event| { - write_event(writer, settings, environment, state, data, event) + write_event( + writer, + settings, + environment, + &resource_handler, + state, + data, + event, + ) }, )?; finish(writer, settings, environment, final_state, final_data) @@ -133,6 +141,8 @@ where mod tests { use pulldown_cmark::Parser; + use crate::resources::NoopResourceHandler; + use super::*; fn render_string(input: &str, settings: &Settings) -> anyhow::Result { @@ -140,7 +150,7 @@ mod tests { let mut sink = Vec::new(); let env = Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?; - push_tty(settings, &env, &mut sink, source)?; + push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?; Ok(String::from_utf8_lossy(&sink).into()) } @@ -158,7 +168,6 @@ mod tests { render_string( markup, &Settings { - resource_access: ResourceAccess::LocalOnly, syntax_set: &SyntaxSet::default(), terminal_capabilities: TerminalProgram::Dumb.capabilities(), terminal_size: TerminalSize::default(), @@ -303,7 +312,6 @@ Hello Donald[2] render_string( markup, &Settings { - resource_access: ResourceAccess::LocalOnly, syntax_set: &SyntaxSet::default(), terminal_capabilities: TerminalProgram::Dumb.capabilities(), terminal_size: TerminalSize::default(), diff --git a/src/render.rs b/src/render.rs index 683057ea..f0fd1c1a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,4 +1,4 @@ -// Copyright 2020 Sebastian Wiesner +// Copyright Sebastian Wiesner // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -12,6 +12,7 @@ use std::io::Result; use anstyle::Effects; use anstyle::Style; use anyhow::anyhow; +use anyhow::Context; use pulldown_cmark::Event::*; use pulldown_cmark::Tag::*; use pulldown_cmark::{Event, LinkType}; @@ -22,6 +23,8 @@ use tracing::{event, instrument, Level}; use url::Url; use crate::render::highlighting::HIGHLIGHTER; +use crate::resources::ResourceUrlHandler; +use crate::svg; use crate::theme::CombineStyle; use crate::{Environment, Settings}; @@ -42,11 +45,12 @@ pub use state::State; pub use state::StateAndData; #[allow(clippy::cognitive_complexity)] -#[instrument(level = "trace", skip(writer, settings, environment))] +#[instrument(level = "trace", skip(writer, settings, environment, resource_handler))] pub fn write_event<'a, W: Write>( writer: &mut W, settings: &Settings, environment: &Environment, + resource_handler: &dyn ResourceUrlHandler, state: State, data: StateData<'a>, event: Event<'a>, @@ -667,10 +671,19 @@ pub fn write_event<'a, W: Write>( terminology.write_inline_image(writer, settings.terminal_size, url)?; Some(RenderedImage) } - (Some(ITerm2(iterm2)), Some(ref url)) => iterm2 - .read_and_render(url, settings.resource_access) + (Some(ITerm2(iterm2)), Some(ref url)) => + resource_handler + .read_resource(url) + .with_context(|| format!("Failed to read resource from {url}")) + .and_then(|mime_data| { + if mime_data.mime_type == Some(mime::IMAGE_SVG) { + svg::render_svg(&mime_data.data) + } else { + Ok(mime_data.data) + } + }) .map_err(|error| { - event!(Level::ERROR, ?error, %url, ?settings.resource_access, "failed to render image in iterm2: {:#}", error); + event!(Level::ERROR, ?error, %url, "failed to render image in iterm2: {:#}", error); error }) .and_then(|contents| { @@ -692,10 +705,13 @@ pub fn write_event<'a, W: Write>( anyhow!("Terminal pixel size not available") }) .and_then(|size| { - let image = kitty.read_and_render(url, settings.resource_access, size).map_err(|error| { - event!(Level::ERROR, ?error, %url, ?settings.resource_access, "failed to render image in kitty: {:#}", error); - error - })?; + let image = resource_handler.read_resource(url) + .with_context(|| format!("Failed to read data from {url}")) + .and_then(|mime_data| kitty.render(url, mime_data, size)) + .map_err(|error| { + event!(Level::ERROR, ?error, %url, "failed to render image in kitty: {:#}", error); + error + })?; kitty.write_inline_image(writer, image).map_err(|error| { event!(Level::ERROR, ?error, "failed to write iterm kitty: {:#}", error); error diff --git a/src/resources.rs b/src/resources.rs index b261b18b..1b662c04 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -6,440 +6,110 @@ //! Access to resources referenced from markdown documents. -use std::fs::File; -use std::io::prelude::*; -use std::time::Duration; +use std::fmt::Debug; +use std::io::{Error, ErrorKind, Result}; -use anyhow::{anyhow, Context, Result}; use mime::Mime; -use once_cell::sync::Lazy; -use reqwest::{ - blocking::{Client, ClientBuilder}, - header::CONTENT_TYPE, -}; -use tracing::{event, Level}; use url::Url; -static CLIENT: Lazy> = Lazy::new(|| { - let proxies = system_proxy::env::from_curl_env(); - ClientBuilder::new() - // Use env_proxy to extract proxy information from the environment; it's more flexible and - // accurate than reqwest's built-in env proxy support. - .proxy(reqwest::Proxy::custom(move |url| { - proxies.lookup(url).map(Clone::clone) - })) - // Use somewhat aggressive timeouts to avoid blocking rendering for long; we have graceful - // fallbacks since we have to support terminals without image capabilities anyways. - .timeout(Some(Duration::from_millis(100))) - .connect_timeout(Some(Duration::from_secs(1))) - .referer(false) - .user_agent(concat!("mdcat/", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|error| { - event!( - Level::ERROR, - ?error, - "Failed to initialize HTTP client: {}", - error - ); - error - }) - .ok() -}); +mod file; +mod http; -/// What kind of resources mdcat may access when rendering. -/// -/// This struct denotes whether mdcat shows inline images from remote URLs or -/// just from local files. -#[derive(Debug, Copy, Clone)] -pub enum ResourceAccess { - /// Use only local files and prohibit remote resources. - LocalOnly, - /// Use local and remote resources alike. - RemoteAllowed, -} +pub use file::FileResourceHandler; +pub use http::HttpResourceHandler; -impl ResourceAccess { - /// Whether the resource access permits access to the given `url`. - pub fn permits(self, url: &Url) -> bool { - match self { - ResourceAccess::LocalOnly if is_local(url) => true, - ResourceAccess::RemoteAllowed => true, - _ => false, - } - } -} +/// Default read size limit for resources. +pub static DEFAULT_RESOURCE_READ_LIMIT: u64 = 104_857_600; -/// Whether `url` is readable as local file. -fn is_local(url: &Url) -> bool { - url.scheme() == "file" && url.to_file_path().is_ok() +/// Data of a resource with associated mime type. +#[derive(Debug, Clone)] +pub struct MimeData { + /// The mime type if known. + pub mime_type: Option, + /// The data. + pub data: Vec, } -/// Read size limit for resources. -static RESOURCE_READ_LIMIT: u64 = 104_857_600; - -fn fetch_http(url: &Url) -> Result<(Option, Vec)> { - let response = CLIENT - .as_ref() - .with_context(|| "HTTP client not available".to_owned())? - .get(url.clone()) - .send() - .with_context(|| format!("Failed to GET {url}"))? - .error_for_status()?; - - let content_type = response.headers().get(CONTENT_TYPE); - event!( - Level::DEBUG, - "Raw Content-Type of remote resource {}: {:?}", - &url, - &content_type - ); - let mime_type = content_type - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - event!( - Level::DEBUG, - "Parsed Content-Type of remote resource {}: {:?}", - &url, - &mime_type - ); - - match response.content_length() { - // The server gave us no content size so read until the end of the stream, but not more than our read limit. - None => { - // An educated guess for a good capacity, - let mut buffer = Vec::with_capacity(1_048_576); - // We read one byte more than our limit, so that we can differentiate between a regular EOF and one from hitting the limit. - response - .take(RESOURCE_READ_LIMIT + 1) - .read_to_end(&mut buffer) - .with_context(|| format!("Failed to read from {url}"))?; - - if RESOURCE_READ_LIMIT < buffer.len() as u64 { - Err(anyhow!( - "Contents of {url} exceeded {RESOURCE_READ_LIMIT}, rejected", - )) - } else { - Ok((mime_type, buffer)) - } - } - // If we've got a content-size use it to read exactly as many bytes as the server told us to do (within limits) - Some(size) => { - if RESOURCE_READ_LIMIT < size { - Err(anyhow!( - "{url} reports size {size} which exceeds limit {RESOURCE_READ_LIMIT}, refusing to read", - )) - } else { - let mut buffer = vec![0; size as usize]; - response - // Just to be on the safe side limit the read operation explicitly, just in case we got the above check wrong - .take(RESOURCE_READ_LIMIT) - .read_exact(buffer.as_mut_slice()) - .with_context(|| format!("Failed to read from {url}"))?; +/// Handle resource URLs. +pub trait ResourceUrlHandler: Send + Sync + Debug { + /// Read a resource. + /// + /// Read data from the given `url`, and return the data and its associated mime type if known, + /// or any IO error which occurred while reading from the resource. + /// + /// Alternatively, return an IO error with [`ErrorKind::Unsupported`] to indicate that the + /// given `url` is not supported by this resource handler. In this case a higher level + /// resource handler may try a different handler. + fn read_resource(&self, url: &Url) -> Result; +} - Ok((mime_type, buffer)) - } - } +impl<'a, R: ResourceUrlHandler + ?Sized> ResourceUrlHandler for &'a R { + fn read_resource(&self, url: &Url) -> Result { + (*self).read_resource(url) } } -/// Read the contents of the given `url` if supported. +/// Filter by URL scheme. /// -/// Fail if -/// -/// - we don’t know how to read from `url`, i.e. the scheme's not supported, -/// - if we fail to read from URL, or -/// - if contents of the URL exceed an internal hard-coded size limit (currently 100 MiB). -/// -/// We currently support `file:` URLs which the underlying operation system can -/// read (local on UNIX, UNC paths on Windows), and HTTP(S) URLs. -pub fn read_url(url: &Url, access: ResourceAccess) -> Result<(Option, Vec)> { - if !access.permits(url) { - return Err(anyhow!( - "Access denied to URL {} by policy {:?}", - url, - access - )); - } - match url.scheme() { - "file" => match url.to_file_path() { - Ok(path) => { - let mut buffer = Vec::new(); - File::open(&path) - .with_context(|| format!("Failed to open file at {url}"))? - // Read a byte more than the limit differentiate an expected EOF from hitting the limit - .take(RESOURCE_READ_LIMIT + 1) - .read_to_end(&mut buffer) - .with_context(|| format!("Failed to read from file at {url}"))?; - - if RESOURCE_READ_LIMIT < buffer.len() as u64 { - Err(anyhow!( - "Contents of {url} exceeded {RESOURCE_READ_LIMIT}, rejected", - )) - } else { - let mime_type = mime_guess::from_path(&path).first(); - if mime_type.is_none() { - event!( - Level::DEBUG, - "Failed to guess mime type from {}", - path.display() - ); - } - Ok((mime_type, buffer)) - } - } - Err(_) => Err(anyhow!("Cannot convert URL {url} to file path")), - }, - "http" | "https" => fetch_http(url), - _ => Err(anyhow!( - "Cannot read from URL {url}, protocol not supported", - )), +/// Return `Ok(url)` if `url` has the given `scheme`, otherwise return an IO error with error kind +/// [`ErrorKind::Unsupported`]. +pub fn filter_schemes<'a>(schemes: &[&str], url: &'a Url) -> Result<&'a Url> { + if schemes.contains(&url.scheme()) { + Ok(url) + } else { + Err(Error::new( + ErrorKind::Unsupported, + format!("Unsupported scheme in {url}, expected one of {schemes:?}"), + )) } } -#[cfg(test)] -mod tests { - use std::{convert::Infallible, net::SocketAddr}; - - use super::*; - use hyper::{ - body::Bytes, - service::{make_service_fn, service_fn}, - Body, Request, Response, Server, - }; - use pretty_assertions::assert_eq; - use tokio::{runtime::Runtime, sync::oneshot, task::JoinHandle}; - - #[test] - #[cfg(unix)] - fn resource_access_permits_local_resource() { - let resource = Url::parse("file:///foo/bar").unwrap(); - assert!(ResourceAccess::LocalOnly.permits(&resource)); - assert!(ResourceAccess::RemoteAllowed.permits(&resource)); - } - - #[test] - #[cfg(unix)] - fn resource_access_permits_remote_file_url() { - let resource = Url::parse("file://example.com/foo/bar").unwrap(); - assert!(!ResourceAccess::LocalOnly.permits(&resource)); - assert!(ResourceAccess::RemoteAllowed.permits(&resource)); - } - - #[test] - fn resource_access_permits_https_url() { - let resource = Url::parse("https:///foo/bar").unwrap(); - assert!(!ResourceAccess::LocalOnly.permits(&resource)); - assert!(ResourceAccess::RemoteAllowed.permits(&resource)); - } - - #[test] - fn read_url_with_local_path_returns_content_type() { - let cwd = Url::from_directory_path(std::env::current_dir().unwrap()).unwrap(); - - let resource = cwd.join("sample/rust-logo.svg").unwrap(); - let (mime_type, _) = read_url(&resource, ResourceAccess::LocalOnly).unwrap(); - assert_eq!(mime_type, Some(mime::IMAGE_SVG)); - - let resource = cwd.join("sample/rust-logo-128x128.png").unwrap(); - let (mime_type, _) = read_url(&resource, ResourceAccess::LocalOnly).unwrap(); - assert_eq!(mime_type, Some(mime::IMAGE_PNG)); - } - - #[test] - fn read_url_with_http_url_fails_if_local_only_access() { - let url = "https://github.com".parse::().unwrap(); - let error = read_url(&url, ResourceAccess::LocalOnly) - .unwrap_err() - .to_string(); - assert_eq!( - error, - "Access denied to URL https://github.com/ by policy LocalOnly" - ); - } - - async fn mock_service(req: Request) -> Result, Infallible> { - let response = match req.uri().path() { - "/png" => Response::builder() - .status(200) - .header("content-type", "image/png") - .body(Body::from("would-be-a-png-image")) - .unwrap(), - "/empty-response" => Response::builder().status(201).body(Body::empty()).unwrap(), - "/drip-very-slow" => { - let (mut sender, body) = Body::channel(); - let size = 30_000; - tokio::spawn(async move { - for chunk in std::iter::repeat(Bytes::copy_from_slice(&[b'x'; 1000])).take(size) - { - if sender - .send_data(Bytes::copy_from_slice(&chunk)) - .await - .is_err() - { - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - }); - Response::builder() - .status(200) - .header("content-length", size * 1000) - .header("content-type", "application/octet-stream") - .body(body) - .unwrap() - } - // Drip-feed a very very large response with a 1kb chunk per 250ms, with content-length - // set appropriately. - "/drip-large" => { - let (mut sender, body) = Body::channel(); - let size = 150_000; - tokio::spawn(async move { - for chunk in std::iter::repeat(Bytes::copy_from_slice(&[b'x'; 1000])).take(size) - { - if sender - .send_data(Bytes::copy_from_slice(&chunk)) - .await - .is_err() - { - break; - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - }); - Response::builder() - .status(200) - .header("content-length", size * 1000) - .header("content-type", "application/octet-stream") - .body(body) - .unwrap() - } - _ => Response::builder().status(404).body(Body::empty()).unwrap(), - }; - Ok(response) - } - - struct MockServer { - runtime: Runtime, - join_handle: Option>, - terminate_server: Option>, - local_addr: SocketAddr, - } - - impl MockServer { - fn start() -> Self { - let addr: SocketAddr = "[::1]:0".parse().unwrap(); - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - .unwrap(); - let (terminate_sender, terminate_receiver) = oneshot::channel(); - let (addr_sender, addr_receiver) = oneshot::channel(); - let join_handle = runtime.spawn(async move { - let make_service = make_service_fn(|_conn| async { - Ok::<_, Infallible>(service_fn(mock_service)) - }); - let server = Server::bind(&addr).serve(make_service); - addr_sender.send(server.local_addr()).unwrap(); - let shutdown = server.with_graceful_shutdown(async { - terminate_receiver.await.ok().unwrap_or_default() - }); - let _ = shutdown.await; - }); - let local_addr = runtime.block_on(addr_receiver).unwrap(); - Self { - join_handle: Some(join_handle), - runtime, - terminate_server: Some(terminate_sender), - local_addr, - } - } +/// A resource handler which dispatches reading among a list of inner handlers. +#[derive(Debug)] +pub struct DispatchingResourceHandler { + /// Inner handlers. + handlers: Vec>, +} - fn url(&self) -> Url { - let mut url = Url::parse("http://localhost").unwrap(); - url.set_port(Some(self.local_addr.port())).unwrap(); - url.set_ip_host(self.local_addr.ip()).unwrap(); - url - } +impl DispatchingResourceHandler { + /// Create a new handler wrapping all given `handlers`. + pub fn new(handlers: Vec>) -> Self { + Self { handlers } } +} - impl Drop for MockServer { - fn drop(&mut self) { - if let Some(terminate) = self.terminate_server.take() { - terminate.send(()).ok(); - } - if let Some(handle) = self.join_handle.take() { - self.runtime.block_on(handle).ok(); +impl ResourceUrlHandler for DispatchingResourceHandler { + /// Read from the given resource `url`. + /// + /// Try every inner handler one after another, while handlers return an + /// [`ErrorKind::Unsupported`] IO error. For any other error abort and return the error. + /// + /// Return the first different result, i.e. either data read or another error. + fn read_resource(&self, url: &Url) -> Result { + for handler in &self.handlers { + match handler.read_resource(url) { + Ok(data) => return Ok(data), + Err(error) if error.kind() == ErrorKind::Unsupported => continue, + Err(error) => return Err(error), } } + Err(Error::new( + ErrorKind::Unsupported, + format!("No handler supported reading from {url}"), + )) } +} - #[test] - fn read_url_with_http_url_fails_when_status_404() { - let server = MockServer::start(); - let url = server.url().join("really-not-there").unwrap(); - let result = read_url(&url, ResourceAccess::RemoteAllowed); - assert!(result.is_err(), "Unexpected success: {result:?}"); - assert_eq!( - format!("{:#}", result.unwrap_err()), - format!("HTTP status client error (404 Not Found) for url ({url})") - ) - } - - #[test] - fn read_url_with_http_url_empty_response() { - let server = MockServer::start(); - let url = server.url().join("/empty-response").unwrap(); - let result = read_url(&url, ResourceAccess::RemoteAllowed); - assert!(result.is_ok(), "Unexpected error: {result:?}"); - let (mime_type, contents) = result.unwrap(); - assert_eq!(mime_type, None); - assert!(contents.is_empty(), "Contents not empty: {contents:?}"); - } - - #[test] - fn read_url_with_http_url_returns_content_type() { - let server = MockServer::start(); - let url = server.url().join("/png").unwrap(); - let result = read_url(&url, ResourceAccess::RemoteAllowed); - assert!(result.is_ok(), "Unexpected error: {result:?}"); - let (mime_type, contents) = result.unwrap(); - assert_eq!(mime_type, Some(mime::IMAGE_PNG)); - assert_eq!( - std::str::from_utf8(&contents).unwrap(), - "would-be-a-png-image" - ); - } - - #[test] - fn read_url_with_http_url_times_out_fast_on_slow_response() { - let server = MockServer::start(); - // Read from a small but slow response: We wouldn't hit the size limit, but we should time - // out aggressively. - let url = server.url().join("/drip-very-slow").unwrap(); - let result = read_url(&url, ResourceAccess::RemoteAllowed); - assert!(result.is_err(), "Unexpected success: {result:?}"); - let error = format!("{:#}", result.unwrap_err()); - assert_eq!( - error, - format!("Failed to read from {url}: error decoding response body: operation timed out: operation timed out") - ); - } - - #[test] - fn read_url_with_http_url_fails_fast_when_size_limit_is_exceeded() { - let server = MockServer::start(); - // Read from a large and slow response: The response would take eternal to complete, but - // since we abort right after checking the size limit, this test fails fast instead of - // trying to read the entire request. - let url = server.url().join("/drip-large").unwrap(); - let result = read_url(&url, ResourceAccess::RemoteAllowed); - assert!(result.is_err(), "Unexpected success: {result:?}"); - let error = format!("{:#}", result.unwrap_err()); - assert_eq!( - error, - format!("{url} reports size 150000000 which exceeds limit 104857600, refusing to read") - ); +/// A resource handler which doesn't read anything. +#[derive(Debug, Clone, Copy)] +pub struct NoopResourceHandler; + +impl ResourceUrlHandler for NoopResourceHandler { + /// Always return an [`ErrorKind::Unsupported`] error. + fn read_resource(&self, url: &Url) -> Result { + Err(Error::new( + ErrorKind::Unsupported, + format!("Reading from resource {url} is not supported"), + )) } } diff --git a/src/resources/file.rs b/src/resources/file.rs new file mode 100644 index 00000000..aba09b4e --- /dev/null +++ b/src/resources/file.rs @@ -0,0 +1,117 @@ +// Copyright 2018-2020 Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//! File resources. + +use std::fs::File; +use std::io::prelude::*; +use std::io::{Error, ErrorKind, Result}; + +use tracing::{event, Level}; +use url::Url; + +use super::{filter_schemes, MimeData, ResourceUrlHandler}; + +/// A resource handler for `file:` URLs. +#[derive(Debug, Clone)] +pub struct FileResourceHandler { + read_limit: u64, +} + +impl FileResourceHandler { + /// Create a resource handler for `file:` URLs. + /// + /// The resource handler does not read beyond `read_limit`. + pub fn new(read_limit: u64) -> Self { + Self { read_limit } + } +} + +impl ResourceUrlHandler for FileResourceHandler { + fn read_resource(&self, url: &Url) -> Result { + filter_schemes(&["file"], url).and_then(|url| { + match url.to_file_path() { + Ok(path) => { + let mut buffer = Vec::new(); + File::open(&path)? + // Read a byte more than the limit differentiate an expected EOF from hitting the limit + .take(self.read_limit + 1) + .read_to_end(&mut buffer)?; + + if self.read_limit < buffer.len() as u64 { + Err(Error::new( + ErrorKind::InvalidData, + // TODO: Use ErrorKind::FileTooLarge once stabilized + format!("Contents of {url} exceeded {} bytes", self.read_limit), + )) + } else { + let mime_type = mime_guess::from_path(&path).first(); + if mime_type.is_none() { + event!( + Level::DEBUG, + "Failed to guess mime type from {}", + path.display() + ); + } + Ok(MimeData { + mime_type, + data: buffer, + }) + } + } + Err(_) => Err(Error::new( + ErrorKind::InvalidInput, + format!("Cannot convert URL {url} to file path"), + )), + } + }) + } +} + +#[cfg(test)] +mod tests { + use crate::resources::*; + use pretty_assertions::assert_eq; + use reqwest::Url; + + #[test] + fn read_resource_returns_content_type() { + let cwd = Url::from_directory_path(std::env::current_dir().unwrap()).unwrap(); + let client = FileResourceHandler { + read_limit: DEFAULT_RESOURCE_READ_LIMIT, + }; + + let resource = cwd.join("sample/rust-logo.svg").unwrap(); + let mime_type = client.read_resource(&resource).unwrap().mime_type; + assert_eq!(mime_type, Some(mime::IMAGE_SVG)); + + let resource = cwd.join("sample/rust-logo-128x128.png").unwrap(); + let mime_type = client.read_resource(&resource).unwrap().mime_type; + assert_eq!(mime_type, Some(mime::IMAGE_PNG)); + } + + #[test] + fn read_resource_obeys_size_limit() { + let cwd = Url::from_directory_path(std::env::current_dir().unwrap()).unwrap(); + let client = FileResourceHandler { read_limit: 10 }; + + let resource = cwd.join("sample/rust-logo.svg").unwrap(); + let error = client.read_resource(&resource).unwrap_err().to_string(); + assert_eq!(error, format!("Contents of {resource} exceeded 10 bytes")); + } + + #[test] + fn read_resource_ignores_http() { + let url = Url::parse("https://example.com").unwrap(); + + let client = FileResourceHandler { read_limit: 10 }; + let error = client.read_resource(&url).unwrap_err().to_string(); + assert_eq!( + error, + "Unsupported scheme in https://example.com/, expected one of [\"file\"]" + ); + } +} diff --git a/src/resources/http.rs b/src/resources/http.rs new file mode 100644 index 00000000..66a4cd74 --- /dev/null +++ b/src/resources/http.rs @@ -0,0 +1,353 @@ +// Copyright 2018-2020 Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//! HTTP resources. + +use std::io::prelude::*; +use std::io::{Error, ErrorKind, Result}; +use std::time::Duration; + +use mime::Mime; +use reqwest::blocking::{Client, ClientBuilder}; +use reqwest::header::CONTENT_TYPE; +use tracing::{event, Level}; +use url::Url; + +use super::{filter_schemes, MimeData, ResourceUrlHandler}; + +/// A client for HTTP resources. +#[derive(Debug, Clone)] +pub struct HttpResourceHandler { + read_limit: u64, + http_client: Client, +} + +impl HttpResourceHandler { + /// Create a new handler for HTTP resources. + /// + /// `read_limit` is the maximum amount of bytes to read from a HTTP resource before failing, + /// and `http_client` is the underlying HTTP client. + pub fn new(read_limit: u64, http_client: Client) -> Self { + Self { + read_limit, + http_client, + } + } + + /// Create a new HTTP resource handler.. + /// + /// `read_limit` is the maximum amount of bytes to read from a HTTP resource, and and + /// `user_agent` is the string to use as user agent for all requests. + /// + /// Create a HTTP client with some standard settings. + pub fn with_user_agent(read_limit: u64, user_agent: &str) -> Result { + let proxies = system_proxy::env::from_curl_env(); + ClientBuilder::new() + // Use env_proxy to extract proxy information from the environment; it's more flexible and + // accurate than reqwest's built-in env proxy support. + .proxy(reqwest::Proxy::custom(move |url| { + proxies.lookup(url).map(Clone::clone) + })) + // Use somewhat aggressive timeouts to avoid blocking rendering for long; we have graceful + // fallbacks since we have to support terminals without image capabilities anyways. + .timeout(Some(Duration::from_millis(100))) + .connect_timeout(Some(Duration::from_secs(1))) + .referer(false) + .user_agent(user_agent) + .build() + .map_err(|err| Error::new(ErrorKind::Other, err)) + .map(|client| HttpResourceHandler::new(read_limit, client)) + } +} + +impl ResourceUrlHandler for HttpResourceHandler { + fn read_resource(&self, url: &Url) -> Result { + filter_schemes(&["http", "https"], url).and_then(|url| { + let response = self + .http_client + .get(url.clone()) + .send() + .and_then(|r| r.error_for_status()) + .map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + + let content_type = response.headers().get(CONTENT_TYPE); + event!( + Level::DEBUG, + "Raw Content-Type of remote resource {}: {:?}", + &url, + &content_type + ); + let mime_type = content_type + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + event!( + Level::DEBUG, + "Parsed Content-Type of remote resource {}: {:?}", + &url, + &mime_type + ); + + match response.content_length() { + // The server gave us no content size so read until the end of the stream, but not more than our read limit. + None => { + // An educated guess for a good capacity, + let mut buffer = Vec::with_capacity(1_048_576); + // We read one byte more than our limit, so that we can differentiate between a regular EOF and one from hitting the limit. + response + .take(self.read_limit + 1) + .read_to_end(&mut buffer) + .map_err(|error| { + Error::new(error.kind(), format!("Failed to read from {url}: {error}")) + })?; + + if self.read_limit < buffer.len() as u64 { + // TODO: Use ErrorKind::FileTooLarge once stabilized + Err(Error::new( + ErrorKind::InvalidData, + format!("Contents of {url} exceeded {}, rejected", self.read_limit), + )) + } else { + Ok(MimeData { + mime_type, + data: buffer, + }) + } + } + // If we've got a content-size use it to read exactly as many bytes as the server told us to do (within limits) + Some(size) => { + if self.read_limit < size { + // TODO: Use ErrorKind::FileTooLarge once stabilized + Err(Error::new( + ErrorKind::InvalidData, + format!("{url} reports size {size} which exceeds limit {}, refusing to read", self.read_limit))) + } else { + let mut buffer = vec![0; size as usize]; + response + // Just to be on the safe side limit the read operation explicitly, just in case we got the above check wrong + .take(self.read_limit) + .read_exact(buffer.as_mut_slice()) + .map_err(|error| { + Error::new(error.kind(), format!("Failed to read from {url}: {error}")) + })?; + + Ok(MimeData { + mime_type, + data: buffer, + }) + } + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + use std::{convert::Infallible, net::SocketAddr}; + + use hyper::body::Bytes; + use hyper::service::{make_service_fn, service_fn}; + use hyper::{Body, Request, Response, Server}; + use once_cell::sync::Lazy; + use tokio::runtime::Runtime; + use tokio::sync::oneshot; + use tokio::task::JoinHandle; + use url::Url; + + use crate::resources::{ResourceUrlHandler, DEFAULT_RESOURCE_READ_LIMIT}; + + use super::HttpResourceHandler; + + async fn mock_service(req: Request) -> Result, Infallible> { + let response = match req.uri().path() { + "/png" => Response::builder() + .status(200) + .header("content-type", "image/png") + .body(Body::from("would-be-a-png-image")) + .unwrap(), + "/empty-response" => Response::builder().status(201).body(Body::empty()).unwrap(), + "/drip-very-slow" => { + let (mut sender, body) = Body::channel(); + let size = 30_000; + tokio::spawn(async move { + for chunk in std::iter::repeat(Bytes::copy_from_slice(&[b'x'; 1000])).take(size) + { + if sender + .send_data(Bytes::copy_from_slice(&chunk)) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); + Response::builder() + .status(200) + .header("content-length", size * 1000) + .header("content-type", "application/octet-stream") + .body(body) + .unwrap() + } + // Drip-feed a very very large response with a 1kb chunk per 250ms, with content-length + // set appropriately. + "/drip-large" => { + let (mut sender, body) = Body::channel(); + let size = 150_000; + tokio::spawn(async move { + for chunk in std::iter::repeat(Bytes::copy_from_slice(&[b'x'; 1000])).take(size) + { + if sender + .send_data(Bytes::copy_from_slice(&chunk)) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }); + Response::builder() + .status(200) + .header("content-length", size * 1000) + .header("content-type", "application/octet-stream") + .body(body) + .unwrap() + } + _ => Response::builder().status(404).body(Body::empty()).unwrap(), + }; + Ok(response) + } + + struct MockServer { + runtime: Runtime, + join_handle: Option>, + terminate_server: Option>, + local_addr: SocketAddr, + } + + impl MockServer { + fn start() -> Self { + let addr: SocketAddr = "[::1]:0".parse().unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let (terminate_sender, terminate_receiver) = oneshot::channel(); + let (addr_sender, addr_receiver) = oneshot::channel(); + let join_handle = runtime.spawn(async move { + let make_service = make_service_fn(|_conn| async { + Ok::<_, Infallible>(service_fn(mock_service)) + }); + let server = Server::bind(&addr).serve(make_service); + addr_sender.send(server.local_addr()).unwrap(); + let shutdown = server.with_graceful_shutdown(async { + terminate_receiver.await.ok().unwrap_or_default() + }); + let _ = shutdown.await; + }); + let local_addr = runtime.block_on(addr_receiver).unwrap(); + Self { + join_handle: Some(join_handle), + runtime, + terminate_server: Some(terminate_sender), + local_addr, + } + } + + fn url(&self) -> Url { + let mut url = Url::parse("http://localhost").unwrap(); + url.set_port(Some(self.local_addr.port())).unwrap(); + url.set_ip_host(self.local_addr.ip()).unwrap(); + url + } + } + + impl Drop for MockServer { + fn drop(&mut self) { + if let Some(terminate) = self.terminate_server.take() { + terminate.send(()).ok(); + } + if let Some(handle) = self.join_handle.take() { + self.runtime.block_on(handle).ok(); + } + } + } + + static CLIENT: Lazy = Lazy::new(|| { + HttpResourceHandler::with_user_agent(DEFAULT_RESOURCE_READ_LIMIT, "foo/0.0").unwrap() + }); + + #[test] + fn read_url_with_http_url_fails_when_status_404() { + let server = MockServer::start(); + let url = server.url().join("really-not-there").unwrap(); + let result = CLIENT.read_resource(&url); + assert!(result.is_err(), "Unexpected success: {result:?}"); + assert_eq!( + format!("{:#}", result.unwrap_err()), + format!("HTTP status client error (404 Not Found) for url ({url})") + ) + } + + #[test] + fn read_url_with_http_url_empty_response() { + let server = MockServer::start(); + let url = server.url().join("/empty-response").unwrap(); + let result = CLIENT.read_resource(&url); + assert!(result.is_ok(), "Unexpected error: {result:?}"); + let data = result.unwrap(); + assert_eq!(data.mime_type, None); + assert!(data.data.is_empty(), "Data not empty: {:?}", data.data); + } + + #[test] + fn read_url_with_http_url_returns_content_type() { + let server = MockServer::start(); + let url = server.url().join("/png").unwrap(); + let result = CLIENT.read_resource(&url); + assert!(result.is_ok(), "Unexpected error: {result:?}"); + let data = result.unwrap(); + assert_eq!(data.mime_type, Some(mime::IMAGE_PNG)); + assert_eq!( + std::str::from_utf8(&data.data).unwrap(), + "would-be-a-png-image" + ); + } + + #[test] + fn read_url_with_http_url_times_out_fast_on_slow_response() { + let server = MockServer::start(); + // Read from a small but slow response: We wouldn't hit the size limit, but we should time + // out aggressively. + let url = server.url().join("/drip-very-slow").unwrap(); + let result = CLIENT.read_resource(&url); + assert!(result.is_err(), "Unexpected success: {result:?}"); + let error = format!("{:#}", result.unwrap_err()); + assert_eq!( + error, + format!("Failed to read from {url}: error decoding response body: operation timed out") + ); + } + + #[test] + fn read_url_with_http_url_fails_fast_when_size_limit_is_exceeded() { + let server = MockServer::start(); + // Read from a large and slow response: The response would take eternal to complete, but + // since we abort right after checking the size limit, this test fails fast instead of + // trying to read the entire request. + let url = server.url().join("/drip-large").unwrap(); + let result = CLIENT.read_resource(&url); + assert!(result.is_err(), "Unexpected success: {result:?}"); + let error = format!("{:#}", result.unwrap_err()); + assert_eq!( + error, + format!("{url} reports size 150000000 which exceeds limit 104857600, refusing to read") + ); + } +} diff --git a/src/terminal/capabilities/iterm2.rs b/src/terminal/capabilities/iterm2.rs index b26a1ce2..e9e7e45b 100644 --- a/src/terminal/capabilities/iterm2.rs +++ b/src/terminal/capabilities/iterm2.rs @@ -10,15 +10,10 @@ use std::io::{self, Write}; -use anyhow::{Context, Result}; use base64::engine::general_purpose::STANDARD; use base64::Engine; -use url::Url; -use crate::resources::read_url; -use crate::svg; use crate::terminal::osc::write_osc; -use crate::ResourceAccess; /// Iterm2 marks. #[derive(Debug, Copy, Clone)] @@ -60,17 +55,4 @@ impl ITerm2Images { ), ) } - - /// Read `url` and render to an image if necessary. - /// - /// Render the binary content of the (rendered) image or an IO error if - /// reading or rendering failed. - pub fn read_and_render(self, url: &Url, access: ResourceAccess) -> Result> { - let (mime_type, contents) = read_url(url, access)?; - if mime_type == Some(mime::IMAGE_SVG) { - svg::render_svg(&contents).with_context(|| format!("Failed to render SVG at URL {url}")) - } else { - Ok(contents) - } - } } diff --git a/src/terminal/capabilities/kitty.rs b/src/terminal/capabilities/kitty.rs index d2a78a8e..db96880b 100644 --- a/src/terminal/capabilities/kitty.rs +++ b/src/terminal/capabilities/kitty.rs @@ -25,10 +25,9 @@ use image::ColorType; use image::{DynamicImage, GenericImageView}; use url::Url; -use crate::resources::read_url; +use crate::resources::MimeData; use crate::svg::render_svg; use crate::terminal::size::PixelSize; -use crate::ResourceAccess; /// Provides access to printing images for kitty. #[derive(Debug, Copy, Clone)] @@ -104,32 +103,31 @@ impl KittyImages { Ok(()) } - /// Read the image bytes from the given URL and wrap them in a `KittyImage`. + /// Render mime data obtained from `url` and wrap it in a `KittyImage`. /// /// If the image size exceeds `terminal_size` in either dimension scale the /// image down to `terminal_size` (preserving aspect ratio). - pub fn read_and_render( + pub fn render( self, url: &Url, - access: ResourceAccess, + mime_data: MimeData, terminal_size: PixelSize, ) -> Result { - let (mime_type, contents) = read_url(url, access)?; - let image = if mime_type == Some(mime::IMAGE_SVG) { + let image = if mime_data.mime_type == Some(mime::IMAGE_SVG) { image::load_from_memory( - &render_svg(&contents) + &render_svg(&mime_data.data) .with_context(|| format!("Failed to render SVG at {url} to PNG"))?, ) .with_context(|| format!("Failed to load SVG rendered from {url}"))? } else { - image::load_from_memory(&contents) + image::load_from_memory(&mime_data.data) .with_context(|| format!("Failed to load image from URL {url}"))? }; - if mime_type == Some(mime::IMAGE_PNG) + if mime_data.mime_type == Some(mime::IMAGE_PNG) && PixelSize::from_xy(image.dimensions()) <= terminal_size { - Ok(self.render_as_png(contents)) + Ok(self.render_as_png(mime_data.data)) } else { Ok(self.render_as_rgb_or_rgba(image, terminal_size)) } diff --git a/tests/render.rs b/tests/render.rs index f6cffbd3..4002532e 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -15,6 +15,10 @@ use std::path::Path; use anyhow::{Context, Result}; use glob::glob; +use mdcat::resources::{ + DispatchingResourceHandler, FileResourceHandler, HttpResourceHandler, ResourceUrlHandler, + DEFAULT_RESOURCE_READ_LIMIT, +}; use once_cell::sync::Lazy; use pretty_assertions::assert_eq; use pulldown_cmark::{Options, Parser}; @@ -26,6 +30,20 @@ use mdcat::{Environment, Theme}; static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); +static RESOURCE_HANDLER: Lazy = Lazy::new(|| { + let handlers: Vec> = vec![ + Box::new(FileResourceHandler::new(DEFAULT_RESOURCE_READ_LIMIT)), + Box::new( + HttpResourceHandler::with_user_agent( + DEFAULT_RESOURCE_READ_LIMIT, + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")), + ) + .unwrap(), + ), + ]; + DispatchingResourceHandler::new(handlers) +}); + fn render_to_string>( markdown_file: P, settings: &mdcat::Settings, @@ -54,7 +72,7 @@ fn render_to_string>( hostname: "HOSTNAME".to_string(), ..Environment::for_local_directory(&base_dir)? }; - mdcat::push_tty(settings, &env, &mut sink, parser).with_context(|| { + mdcat::push_tty(settings, &env, &*RESOURCE_HANDLER, &mut sink, parser).with_context(|| { format!( "Failed to render contents of {}", markdown_file.as_ref().display() @@ -142,7 +160,6 @@ fn ansi_only() { let settings = mdcat::Settings { terminal_capabilities: TerminalProgram::Ansi.capabilities(), terminal_size: mdcat::terminal::TerminalSize::default(), - resource_access: mdcat::ResourceAccess::LocalOnly, theme: Theme::default(), syntax_set: &SYNTAX_SET, }; @@ -160,7 +177,6 @@ fn iterm2() { let settings = mdcat::Settings { terminal_capabilities: TerminalProgram::ITerm2.capabilities(), terminal_size: mdcat::terminal::TerminalSize::default(), - resource_access: mdcat::ResourceAccess::LocalOnly, theme: Theme::default(), syntax_set: &SYNTAX_SET, }; diff --git a/tests/wrapping.rs b/tests/wrapping.rs index 46876f51..d7abb8ed 100644 --- a/tests/wrapping.rs +++ b/tests/wrapping.rs @@ -14,13 +14,12 @@ use pulldown_cmark::{Options, Parser}; use syntect::parsing::SyntaxSet; use anyhow::{Context, Result}; -use mdcat::{terminal::TerminalProgram, Environment, Theme}; +use mdcat::{resources::NoopResourceHandler, terminal::TerminalProgram, Environment, Theme}; static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); static SETTINGS_ANSI_ONLY: Lazy = Lazy::new(|| mdcat::Settings { terminal_capabilities: TerminalProgram::Ansi.capabilities(), terminal_size: mdcat::terminal::TerminalSize::default(), - resource_access: mdcat::ResourceAccess::LocalOnly, theme: Theme::default(), syntax_set: &SYNTAX_SET, }); @@ -35,7 +34,7 @@ fn render_to_string>(markdown: S, settings: &mdcat::Settings) -> R hostname: "HOSTNAME".to_string(), ..Environment::for_local_directory(&std::env::current_dir()?)? }; - mdcat::push_tty(settings, &env, &mut sink, parser)?; + mdcat::push_tty(settings, &env, &NoopResourceHandler, &mut sink, parser)?; String::from_utf8(sink).with_context(|| "Failed to convert rendered result to string") }