onionRush 1.0.0

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

mod args;
mod chunker;
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) {
    let env_filter = if verbose { "debug" } 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(dl_args) => {
            setup_logging(dl_args.verbose);
            println!("[+] onionRush v1.0.0 :: Download mode");

            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?")?;

            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) =>
                {
                    println!("[*] Found resume state from a previous run, verifying integrity...");
                    m
                }
                Some(_) => {
                    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 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() => {}
                        _ => {
                            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 {
                println!(
                    "[*] Resuming: {} of {} chunks already verified complete, {} remaining",
                    already_done,
                    manifest.chunks.len(),
                    pending.len()
                );
            }

            let mut failures = 0;

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

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

                for chunk in pending {
                    let pb = 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 {
                        let progress = (completed + failures) as f64 / total as f64 * 100.0;
                        eprint!("\r[*] Progress: {:.1}% ({} completed, {} failed)", progress, completed, failures);
                    }
                }
                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()));
            }

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

            println!("[+] Download complete! File saved to: {:?}", out_path);
            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);
            println!("[+] onionRush v1.0.0 :: Upload mode");
            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("=>-")
}