use anyhow::{bail, Context, Result};
use std::io::Read;
use std::path::PathBuf;
use crate::cli::Args;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
Stdin,
File(PathBuf),
Http(String),
}
pub fn resolve(args: &Args) -> Result<SourceKind> {
let positional = args.target_url();
if positional.is_empty() {
if stdin_is_pipe() {
return Ok(SourceKind::Stdin);
}
bail!("no input source; pass a path, URL, or pipe to stdin");
}
if positional == "-" {
return Ok(SourceKind::Stdin);
}
let lower = positional.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok(SourceKind::Http(positional.to_string()));
}
if lower.starts_with("file://") {
return resolve_file_url(positional);
}
for scheme in ["ssh://", "scp://", "telnet://"] {
if lower.starts_with(scheme) {
let name = scheme.trim_end_matches("://");
bail!("source-layer features don't support {name}:// URLs");
}
}
Ok(SourceKind::File(PathBuf::from(positional)))
}
pub(crate) fn resolve_file_url(raw: &str) -> Result<SourceKind> {
let rest = &raw[7..]; let (host, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let host_ok = host.is_empty() || host.eq_ignore_ascii_case("localhost");
if !host_ok {
bail!(
"file:// sources must use an empty host or 'localhost'; got '{host}'"
);
}
if path.is_empty() {
bail!("file:// URL '{raw}' has no path component");
}
Ok(SourceKind::File(PathBuf::from(path)))
}
#[cfg(test)]
thread_local! {
static STDIN_IS_PIPE_OVERRIDE: std::cell::Cell<Option<bool>> =
const { std::cell::Cell::new(None) };
}
fn stdin_is_pipe() -> bool {
#[cfg(test)]
{
if let Some(v) = STDIN_IS_PIPE_OVERRIDE.with(|c| c.get()) {
return v;
}
}
use std::io::IsTerminal;
!std::io::stdin().is_terminal()
}
pub fn open(source: SourceKind, args: &Args) -> Result<Box<dyn Read>> {
match source {
SourceKind::Stdin => Ok(Box::new(std::io::stdin().lock())),
SourceKind::File(path) => {
let file = std::fs::File::open(&path)
.with_context(|| format!("failed to open file '{}'", path.display()))?;
Ok(Box::new(file))
}
SourceKind::Http(_url) => {
let (response, _metrics) = crate::client::execute(args)
.context("source fetch failed")?;
if args.fail_on_error && response.status().as_u16() >= 400 {
bail!(
"source fetch returned HTTP {} {}",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or(""),
);
}
Ok(Box::new(response))
}
}
}
pub fn read_all(args: &Args) -> Result<Vec<u8>> {
let source = resolve(args)?;
let mut reader = open(source, args)?;
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut buf)
.context("failed to read source")?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn args_with_url(url: &str) -> Args {
Args::try_parse_from(["recon", url]).unwrap()
}
fn args_without_url() -> Args {
Args::try_parse_from(["recon", "--netstatus"]).unwrap()
}
fn with_stdin_pipe<T>(is_pipe: bool, f: impl FnOnce() -> T) -> T {
STDIN_IS_PIPE_OVERRIDE.with(|c| c.set(Some(is_pipe)));
let out = f();
STDIN_IS_PIPE_OVERRIDE.with(|c| c.set(None));
out
}
#[test]
fn resolve_stdin_when_dash() {
let args = args_with_url("-");
assert_eq!(resolve(&args).unwrap(), SourceKind::Stdin);
}
#[test]
fn resolve_stdin_when_empty_and_pipe() {
let args = args_without_url();
with_stdin_pipe(true, || {
assert_eq!(resolve(&args).unwrap(), SourceKind::Stdin);
});
}
#[test]
fn resolve_errors_when_empty_and_tty() {
let args = args_without_url();
with_stdin_pipe(false, || {
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("no input source"), "got: {err}");
});
}
#[test]
fn resolve_http_scheme_https() {
let args = args_with_url("https://example.com/x");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::Http("https://example.com/x".into()),
);
}
#[test]
fn resolve_http_scheme_http() {
let args = args_with_url("http://example.com/x");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::Http("http://example.com/x".into()),
);
}
#[test]
fn resolve_http_scheme_case_insensitive() {
let args = args_with_url("HTTPS://example.com/x");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::Http("HTTPS://example.com/x".into()),
);
}
#[test]
fn resolve_file_scheme_empty_host() {
let args = args_with_url("file:///tmp/foo.bin");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("/tmp/foo.bin")),
);
}
#[test]
fn resolve_file_scheme_localhost_host() {
let args = args_with_url("file://localhost/tmp/foo.bin");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("/tmp/foo.bin")),
);
}
#[test]
fn resolve_file_scheme_localhost_case_insensitive() {
let args = args_with_url("file://LocalHost/tmp/foo.bin");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("/tmp/foo.bin")),
);
}
#[test]
fn resolve_file_scheme_rejects_other_host() {
let args = args_with_url("file://other.example/tmp/foo");
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("empty host or 'localhost'"), "got: {err}");
assert!(err.contains("other.example"), "got: {err}");
}
#[test]
fn resolve_file_scheme_errors_on_missing_path() {
let args = args_with_url("file://");
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("no path component"), "got: {err}");
}
#[test]
fn resolve_rejects_ssh_scheme() {
let args = args_with_url("ssh://server/file");
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("ssh://"), "got: {err}");
}
#[test]
fn resolve_rejects_scp_scheme() {
let args = args_with_url("scp://server/file");
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("scp://"), "got: {err}");
}
#[test]
fn resolve_rejects_telnet_scheme() {
let args = args_with_url("telnet://server");
let err = resolve(&args).unwrap_err().to_string();
assert!(err.contains("telnet://"), "got: {err}");
}
#[test]
fn resolve_treats_bare_word_as_file() {
let args = args_with_url("example.com");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("example.com")),
);
}
#[test]
fn resolve_treats_absolute_path_as_file() {
let args = args_with_url("/var/log/messages");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("/var/log/messages")),
);
}
#[test]
fn resolve_treats_relative_path_as_file() {
let args = args_with_url("./relative/path.bin");
assert_eq!(
resolve(&args).unwrap(),
SourceKind::File(PathBuf::from("./relative/path.bin")),
);
}
#[test]
fn open_file_reads_bytes() {
let tmp_path = std::env::temp_dir().join(format!(
"recon-source-test-{}.bin",
std::process::id()
));
std::fs::write(&tmp_path, b"hello source").unwrap();
let args = Args::try_parse_from(["recon", tmp_path.to_str().unwrap()]).unwrap();
let bytes = read_all(&args).unwrap();
assert_eq!(bytes, b"hello source");
std::fs::remove_file(&tmp_path).ok();
}
#[test]
fn open_file_errors_when_missing() {
let missing = "/tmp/recon-source-does-not-exist-please";
let args = Args::try_parse_from(["recon", missing]).unwrap();
let err = read_all(&args).unwrap_err().to_string();
assert!(err.contains("failed to open file"), "got: {err}");
assert!(err.contains(missing), "got: {err}");
}
#[test]
fn open_file_via_file_scheme() {
let tmp_path = std::env::temp_dir().join(format!(
"recon-source-scheme-{}.bin",
std::process::id()
));
std::fs::write(&tmp_path, b"via scheme").unwrap();
let url = format!("file://{}", tmp_path.display());
let args = Args::try_parse_from(["recon", &url]).unwrap();
let bytes = read_all(&args).unwrap();
assert_eq!(bytes, b"via scheme");
std::fs::remove_file(&tmp_path).ok();
}
}