feat(cli): add --dump original for binary-safe raw response output (#117) (#148)

Fixes #117. Adds an `Original` variant to `--dump` that streams the raw
HTTP response body verbatim to stdout (or `--output`), bypassing the
browser/JS/DOM stack entirely. Useful for binary payloads (jpg, png,
fonts) and any non-HTML resource where parsing through the DOM layer
would corrupt or discard data.

The new path uses ObscuraHttpClient directly, honours --proxy and
--user-agent, and writes bytes through tokio::io::stdout so binary
payloads (including embedded NUL bytes) round-trip cleanly.
This commit is contained in:
Ben Younes authored and GitHub committed 2026-05-14 10:00:07 +00:00
1 parent 2ff0f8fe1d
commit 09df3050f7
2 files changed
+163 -4

No files matched your search

+5 -1
View File
@@ -107,6 +107,10 @@ obscura fetch https://news.ycombinator.com --dump html
# Write dump or eval output to a file
obscura fetch https://example.com --dump text --output page.txt
# Stream the raw response body verbatim (binary-safe; bypasses the JS/DOM layer).
# Use this for images, JSON, JS, CSS, or any non-HTML resource.
obscura fetch https://picsum.photos/200/300 --dump original > photo.jpg
# Fetch through an HTTP or SOCKS proxy
obscura --proxy socks5://127.0.0.1:1080 fetch https://example.com --dump text
@@ -263,7 +267,7 @@ Fetch and render a single page.
| Flag | Default | Description |
|------|---------|-------------|
| `--dump` | `html` | Output: `html`, `text`, or `links` |
| `--dump` | `html` | Output: `html`, `text`, `links`, `markdown`, or `original` (raw response body) |
| `--eval` | — | JavaScript expression to evaluate |
| `--wait-until` | `load` | Wait: `load`, `domcontentloaded`, `networkidle0` |
| `--timeout` | `30` | Maximum navigation time in seconds |
+158 -3
View File
@@ -128,12 +128,16 @@ enum Command {
}
#[derive(Clone, Debug, clap::ValueEnum)]
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
enum DumpFormat {
Html,
Text,
Links,
Markdown,
/// Stream the raw HTTP response body verbatim (binary-safe).
/// Bypasses the browser/JS layer — useful for fetching images,
/// JSON, JS, CSS, or any non-HTML resource (cf. issue #117).
Original,
}
fn print_banner(port: u16) {
@@ -379,6 +383,22 @@ async fn run_fetch(
quiet: bool,
proxy: Option<String>,
) -> anyhow::Result<()> {
// --dump original short-circuits the browser stack entirely: fetch the raw
// response body via HTTP and stream the bytes verbatim. Useful for binary
// payloads (images, fonts, …) and any non-HTML resource where parsing the
// body through the DOM/JS layer would corrupt or discard data.
if dump == DumpFormat::Original {
let bytes = fetch_original_bytes(
url_str,
proxy,
user_agent.clone(),
timeout_secs,
)
.await?;
write_or_print_bytes(&bytes, output.as_ref()).await?;
return Ok(());
}
let context = Arc::new(BrowserContext::with_options("fetch".to_string(), proxy, stealth));
let mut page = Page::new("fetch-page".to_string(), context);
@@ -428,12 +448,40 @@ async fn run_fetch(
DumpFormat::Text => dump_text(&mut page),
DumpFormat::Links => dump_links(&page),
DumpFormat::Markdown => dump_markdown(&mut page),
// Handled above via the short-circuit branch; unreachable here.
DumpFormat::Original => unreachable!("Original dump handled before page navigation"),
};
write_or_print(rendered, output.as_ref()).await?;
Ok(())
}
async fn fetch_original_bytes(
url_str: &str,
proxy: Option<String>,
user_agent: Option<String>,
timeout_secs: u64,
) -> anyhow::Result<Vec<u8>> {
let url = url::Url::parse(url_str)
.map_err(|e| anyhow::anyhow!("Invalid URL '{}': {}", url_str, e))?;
let client = obscura_net::ObscuraHttpClient::with_options(
Arc::new(obscura_net::CookieJar::new()),
proxy.as_deref(),
);
if let Some(ua) = user_agent {
client.set_user_agent(&ua).await;
}
let response = match timeout(Duration::from_secs(timeout_secs), client.fetch(&url)).await {
Ok(Ok(resp)) => resp,
Ok(Err(e)) => anyhow::bail!("Failed to fetch {}: {}", url_str, e),
Err(_) => anyhow::bail!("Timed out fetching {} after {}s", url_str, timeout_secs),
};
Ok(response.body)
}
async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> {
if let Some(path) = output {
tokio::fs::write(path, content)
@@ -445,6 +493,30 @@ async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) ->
Ok(())
}
async fn write_or_print_bytes(
bytes: &[u8],
output: Option<&std::path::PathBuf>,
) -> anyhow::Result<()> {
if let Some(path) = output {
tokio::fs::write(path, bytes)
.await
.map_err(|e| anyhow::anyhow!("Failed to write {}: {}", path.display(), e))?;
} else {
// Write raw bytes to stdout — never println! (would append a newline
// and break binary payloads like JPEG/PNG).
let mut stdout = tokio::io::stdout();
stdout
.write_all(bytes)
.await
.map_err(|e| anyhow::anyhow!("Failed to write to stdout: {}", e))?;
stdout
.flush()
.await
.map_err(|e| anyhow::anyhow!("Failed to flush stdout: {}", e))?;
}
Ok(())
}
async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
loop {
@@ -824,12 +896,95 @@ fn dump_links(page: &Page) -> String {
#[cfg(test)]
mod tests {
use super::{
extract_readable_text, is_quiet_command, merge_proxy, select_log_filter, write_or_print,
Args, Command,
extract_readable_text, fetch_original_bytes, is_quiet_command, merge_proxy,
select_log_filter, write_or_print, write_or_print_bytes, Args, Command, DumpFormat,
};
use clap::Parser;
use obscura_dom::parse_html;
// Issue #117 — `--dump original` short-circuits the browser stack and
// streams the raw response body verbatim, including for binary payloads.
//
// Two tests below pin the behaviour:
// 1. clap accepts `--dump original` as a valid DumpFormat variant.
// 2. `fetch_original_bytes` returns the exact bytes a `file://` URL
// points at (binary-safe round-trip — no UTF-8 decode, no trailing
// newline, no DOM mutation).
// 3. `write_or_print_bytes` writes raw bytes to a file without the
// trailing newline that `println!` would add.
#[test]
fn parsed_fetch_dump_original_is_accepted_by_clap() {
let args = Args::try_parse_from([
"obscura",
"fetch",
"--dump",
"original",
"https://example.com/image.jpg",
])
.expect("clap should accept --dump original");
match args.command {
Some(Command::Fetch { dump, .. }) => {
assert_eq!(dump, DumpFormat::Original);
}
_ => panic!("expected Fetch command"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn fetch_original_bytes_returns_file_contents_verbatim() {
// A real binary payload: a 1×1 transparent PNG (89 50 4E 47 …) —
// exactly the kind of resource #117 wants to stream without HTML/
// JS rendering.
const PNG_BYTES: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let path = std::env::temp_dir().join(format!(
"obscura-fetch-original-test-{}.png",
std::process::id()
));
let _ = tokio::fs::remove_file(&path).await;
tokio::fs::write(&path, PNG_BYTES)
.await
.expect("seed temp PNG fixture");
let file_url = format!("file://{}", path.display());
let bytes = fetch_original_bytes(&file_url, None, None, 5)
.await
.expect("fetch_original_bytes should round-trip the file body");
let _ = tokio::fs::remove_file(&path).await;
assert_eq!(bytes, PNG_BYTES, "raw response body must match the file byte-for-byte");
}
#[tokio::test(flavor = "current_thread")]
async fn write_or_print_bytes_writes_without_trailing_newline() {
// Regression guard for #117: stdout must receive raw bytes. The file
// path used here exercises the file-output branch — println!-style
// output (used by write_or_print) would append a 0x0A byte and
// corrupt binary payloads. write_or_print_bytes must not.
let payload: &[u8] = &[0x00, 0xFF, b'h', b'i', 0x00];
let path = std::env::temp_dir().join(format!(
"obscura-write-bytes-test-{}.bin",
std::process::id()
));
let _ = tokio::fs::remove_file(&path).await;
write_or_print_bytes(payload, Some(&path))
.await
.expect("write_or_print_bytes should write the file");
let read_back = tokio::fs::read(&path).await.expect("read back");
let _ = tokio::fs::remove_file(&path).await;
assert_eq!(read_back, payload, "file bytes must match the payload exactly");
}
#[tokio::test(flavor = "current_thread")]
async fn write_or_print_writes_output_file_with_tokio_fs() {
let path = std::env::temp_dir().join(format!(