onionRush 1.1.0

Parallel multi-circuit downloader and uploader over Tor.
#![allow(non_snake_case)]

mod args;
mod chunker;
mod config;
mod download;
mod proxy;
mod resume;
mod upload;

use anyhow::{anyhow, Context, Result};
use args::{Args, Commands};
use clap::Parser;
use std::path::PathBuf;
use tracing::{error, warn};
use tracing_subscriber;

fn setup_logging(verbose: bool, quiet: bool) {
    let env_filter = if verbose {
        "debug"
    } else if quiet {
        "error"
    } else {
        "info"
    };
    tracing_subscriber::fmt()
        .with_env_filter(env_filter)
        .with_target(false)
        .init();
}

fn output_path(url: &str, output: &Option<String>) -> PathBuf {
    if let Some(o) = output {
        return PathBuf::from(o);
    }

    let name = url
        .rsplit('/')
        .find(|s| !s.is_empty())
        .unwrap_or("download.bin");

    PathBuf::from(name)
}

#[tokio::main]
async fn main() -> Result<()> {
    let args = Args::parse();

    match args.command {
        Commands::Download(mut dl_args) => {
            if dl_args.quiet && dl_args.verbose {
                return Err(anyhow!("--quiet and --verbose cannot be used together"));
            }
            let quiet = dl_args.quiet;

            setup_logging(dl_args.verbose, quiet);

            let file_config = config::ConfigFile::load(&dl_args.config)
                .context("failed to load config file")?;
            dl_args.apply_config(&file_config);

            if !quiet {
                println!("[+] onionRush v{} :: Download mode", env!("CARGO_PKG_VERSION"));
            }

            let out_path = output_path(&dl_args.url, &dl_args.output);

            let probe_client = proxy::build_client(
                dl_args.socks(),
                &proxy::random_identity(),
                std::time::Duration::from_secs(dl_args.timeout()),
                false,
            )?;

            let target = chunker::probe(&probe_client, &dl_args.url)
                .await
                .context("failed to reach target host - is it online and is the SOCKS proxy reachable?")?;

            if !quiet {
                println!("[+] size: {} bytes ({:.2} GB), range support: {}",
                    target.size,
                    target.size as f64 / (1024.0 * 1024.0 * 1024.0),
                    target.accepts_ranges
                );
            }

            let planned_chunks = if let Some(chunk_size_mb) = dl_args.chunk_size_mb {
                chunker::plan_chunks_by_size(target.size, chunk_size_mb)
            } else {
                chunker::plan_chunks(target.size, dl_args.circuits())
            };

            let mut manifest = match resume::Manifest::load(&out_path) {
                Some(m)
                    if m.url == dl_args.url
                        && m.total_size == target.size
                        && m.chunk_layout_matches(&planned_chunks) =>
                {
                    if !quiet {
                        println!("[*] Found resume state from a previous run, verifying integrity...");
                    }
                    m
                }
                Some(_) => {
                    if !quiet {
                        println!("[!] Resume state found but doesn't match this run (URL, remote size, or chunk layout changed). Starting fresh.");
                    }
                    resume::Manifest::delete(&out_path);
                    resume::Manifest::fresh(&dl_args.url, &out_path, target.size, &planned_chunks)
                }
                None => resume::Manifest::fresh(&dl_args.url, &out_path, target.size, &planned_chunks),
            };


            let file_ok = out_path.exists()
                && std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0) == target.size;

            if !file_ok {
                if !quiet && manifest.chunks.iter().any(|c| c.completed) {
                    println!("[!] Output file missing or wrong size; discarding resume state.");
                }
                let file = std::fs::File::create(&out_path)
                    .with_context(|| format!("could not create output file {:?}", out_path))?;
                file.set_len(target.size)?;
                drop(file);
                manifest = resume::Manifest::fresh(&dl_args.url, &out_path, target.size, &planned_chunks);
            }

            for cs in manifest.chunks.iter_mut() {
                if cs.completed {
                    match resume::hash_range(&out_path, cs.start, cs.end).await {
                        Ok(h) if Some(&h) == cs.sha256.as_ref() => {}
                        _ => {
                            if !quiet {
                                println!("[!] Chunk {} failed integrity check, will be re-downloaded", cs.index);
                            }
                            cs.completed = false;
                            cs.sha256 = None;
                        }
                    }
                }
            }
            manifest.save(&out_path)?;

            let pending: Vec<chunker::Chunk> = planned_chunks
                .into_iter()
                .filter(|c| !manifest.chunks.iter().any(|cs| cs.index == c.index && cs.completed))
                .collect();

            let already_done = manifest.chunks.iter().filter(|c| c.completed).count();
            if already_done > 0 && !quiet {
                println!(
                    "[*] Resuming: {} of {} chunks already verified complete, {} remaining",
                    already_done,
                    manifest.chunks.len(),
                    pending.len()
                );
            }

            let mut failures = 0;

            if pending.is_empty() {
                if !quiet {
                    println!("[+] All chunks already complete and verified.");
                }
            } else {
                if !quiet {
                    println!("[*] Downloading {} chunk(s)", pending.len());
                }

                let multi = indicatif::MultiProgress::new();
                let mut set = tokio::task::JoinSet::new();

                for chunk in pending {
                    let pb = if quiet {
                        indicatif::ProgressBar::hidden()
                    } else {
                        multi.add(indicatif::ProgressBar::new(chunk.end - chunk.start + 1))
                    };
                    pb.set_style(style());
                    pb.set_prefix(format!("chunk {}", chunk.index));

                    let dl_args = dl_args.clone();
                    let url = target.url.clone();
                    let out_path_c = out_path.clone();
                    let index = chunk.index;
                    let start = chunk.start;
                    let end = chunk.end;

                    set.spawn(async move {
                        let result = download::download_chunk(&dl_args, &url, &chunk, &out_path_c, &pb).await;
                        match &result {
                            Ok(()) => pb.finish_with_message("[+] done"),
                            Err(e) => pb.finish_with_message(format!("[-] failed: {}", e)),
                        }
                        (index, start, end, result)
                    });
                }

                let mut completed = 0;
                let total = set.len();

                while let Some(res) = set.join_next().await {
                    match res {
                        Ok((index, start, end, Ok(()))) => {
                            match resume::hash_range(&out_path, start, end).await {
                                Ok(hash) => {
                                    if let Some(cs) = manifest.chunks.iter_mut().find(|c| c.index == index) {
                                        cs.completed = true;
                                        cs.sha256 = Some(hash);
                                    }
                                    if let Err(e) = manifest.save(&out_path) {
                                        warn!("[!] could not persist resume state: {}", e);
                                    }
                                    completed += 1;
                                }
                                Err(e) => {
                                    error!("[-] Chunk {} downloaded but could not be hashed: {}", index, e);
                                    failures += 1;
                                }
                            }
                        }
                        Ok((index, _, _, Err(e))) => {
                            error!("[-] Chunk {} failed: {}", index, e);
                            failures += 1;
                        }
                        Err(e) => {
                            error!("[-] Task panicked: {}", e);
                            failures += 1;
                        }
                    }
                    if total > 0 && !quiet {
                        let progress = (completed + failures) as f64 / total as f64 * 100.0;
                        eprint!("\r[*] Progress: {:.1}% ({} completed, {} failed)", progress, completed, failures);
                    }
                }
                if !quiet {
                    eprintln!();
                }
            }

            if failures > 0 {
                println!(
                    "[-] {} chunk(s) failed. Resume state has been saved -- re-run the same command (same URL, output, --circuits/--chunk-size-mb) once the host is back to continue.",
                    failures
                );
                return Err(anyhow!("{} chunk(s) failed, output is incomplete", failures));
            }

            let metadata = std::fs::metadata(&out_path)?;
            if metadata.len() != target.size {
                return Err(anyhow!("File size mismatch: expected {}, got {}", target.size, metadata.len()));
            }

            if let Some(expected) = &dl_args.sha256 {
                if !quiet {
                    println!("[*] Verifying SHA-256 checksum...");
                }
                let actual = resume::hash_range(&out_path, 0, target.size - 1)
                    .await
                    .context("failed to hash completed file for --sha256 verification")?;

                if !actual.eq_ignore_ascii_case(expected) {
                    return Err(anyhow!(
                        "SHA-256 mismatch: expected {}, got {} -- file may be corrupt or the hash you supplied is wrong",
                        expected,
                        actual
                    ));
                }
                if !quiet {
                    println!("[+] SHA-256 verified: {}", actual);
                }
            }

            resume::Manifest::delete(&out_path);

            println!("[+] Download complete! File saved to: {:?}", out_path);
            if !quiet {
                println!("[+] Total size: {} bytes ({:.2} GB)",
                    target.size,
                    target.size as f64 / (1024.0 * 1024.0 * 1024.0)
                );
            }
        }
        Commands::Upload(up_args) => {
            setup_logging(up_args.verbose, false);
            println!("[+] onionRush v{} :: Upload mode", env!("CARGO_PKG_VERSION"));
            println!("[!] WARNING: This tool is for educational purposes only");
            println!("[!] Ensure you have permission to upload to the target");

            upload::upload_file(&up_args).await?;
        }
    }

    Ok(())
}

fn style() -> indicatif::ProgressStyle {
    indicatif::ProgressStyle::with_template("{prefix:>10} [{bar:30}] {bytes}/{total_bytes} {bytes_per_sec} {eta}")
        .unwrap()
        .progress_chars("=>-")
}