931 lines
33 KiB
Rust
931 lines
33 KiB
Rust
use std::{
|
|
collections::VecDeque,
|
|
ffi::OsString,
|
|
fs,
|
|
io::{BufRead, BufReader, Read, Write},
|
|
net::{TcpListener, TcpStream},
|
|
process::{Child, Command, ExitStatus, Stdio},
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicUsize, Ordering},
|
|
},
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use aria2_rust_pro_core::RuntimeConfig;
|
|
use aria2_rust_pro_protocol::{
|
|
Downloader, FixtureHttpDownloader, FtpConfigModel, FtpRequestModel, FtpResponseModel,
|
|
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestHttpConnector,
|
|
ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel,
|
|
transport::{TransportError, TransportErrorKind},
|
|
};
|
|
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue};
|
|
|
|
use super::http_runtime::partition_indexed_work_evenly;
|
|
use super::{
|
|
CliError, CliTransferSource, CommandSurface, Invocation, RuntimeMode, StartupProfile,
|
|
TransferSelection, build_ftp_transfer_parts, build_http_transfer_task, classify_transfer,
|
|
command_surface, derive_http_session, derive_runtime_config, execute,
|
|
execute_http_transfer_with_retry, execute_runtime, execute_runtime_with_context,
|
|
execute_runtime_with_downloader, execute_segment_transfers, load_config_report, merged_profile,
|
|
parse_args, parse_cli, parse_protocol, planned_segment_span, profile_option_map, render_help,
|
|
render_version,
|
|
};
|
|
|
|
fn percent_encode_uri_component(value: &str) -> String {
|
|
let mut encoded = String::with_capacity(value.len());
|
|
for byte in value.bytes() {
|
|
if matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
|
|
encoded.push(char::from(byte));
|
|
} else {
|
|
encoded.push('%');
|
|
std::fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02X}"))
|
|
.expect("writing percent-encoded byte into string must succeed");
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
fn start_live_bt_tracker_fixture() -> (String, thread::JoinHandle<()>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
|
|
let addr = listener.local_addr().expect("local addr should exist");
|
|
let handle = thread::spawn(move || {
|
|
for _ in 0..2 {
|
|
let (mut stream, _) = listener.accept().expect("tracker client should connect");
|
|
let mut request = [0_u8; 2048];
|
|
let read = stream.read(&mut request).expect("request should read");
|
|
let request_slice = request
|
|
.get(..read)
|
|
.expect("read length must stay within request buffer");
|
|
let request_text = String::from_utf8_lossy(request_slice);
|
|
let (payload, path) = if request_text.starts_with("GET /announce?") {
|
|
(
|
|
b"d8:intervali600e10:tracker id12:cli-live-0015:peers6:\x7f\x00\x00\x01\x1a\xe1e"
|
|
.to_vec(),
|
|
"/announce",
|
|
)
|
|
} else {
|
|
(
|
|
b"d8:completei4e10:downloadedi9e10:incompletei2ee".to_vec(),
|
|
"/scrape",
|
|
)
|
|
};
|
|
assert!(request_text.contains(path));
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
|
|
payload.len()
|
|
);
|
|
stream
|
|
.write_all(response.as_bytes())
|
|
.expect("headers should write");
|
|
stream.write_all(&payload).expect("payload should write");
|
|
}
|
|
});
|
|
(format!("http://{addr}/announce"), handle)
|
|
}
|
|
|
|
fn build_single_file_torrent_bytes(
|
|
announce_url: &str,
|
|
file_name: &str,
|
|
total_length: u64,
|
|
piece_length: u64,
|
|
) -> Vec<u8> {
|
|
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
|
|
let mut encoded = format!("{}:", value.len()).into_bytes();
|
|
encoded.extend_from_slice(value);
|
|
encoded
|
|
}
|
|
|
|
fn bencode_int(value: u64) -> Vec<u8> {
|
|
format!("i{value}e").into_bytes()
|
|
}
|
|
|
|
let mut torrent = Vec::new();
|
|
torrent.extend_from_slice(b"d8:announce");
|
|
torrent.extend_from_slice(&bencode_bytes(announce_url.as_bytes()));
|
|
torrent.extend_from_slice(b"4:infod6:length");
|
|
torrent.extend_from_slice(&bencode_int(total_length));
|
|
torrent.extend_from_slice(b"4:name");
|
|
torrent.extend_from_slice(&bencode_bytes(file_name.as_bytes()));
|
|
torrent.extend_from_slice(b"12:piece length");
|
|
torrent.extend_from_slice(&bencode_int(piece_length));
|
|
torrent.extend_from_slice(b"6:pieces20:");
|
|
torrent.extend_from_slice(&[0_u8; 20]);
|
|
torrent.extend_from_slice(b"ee");
|
|
torrent
|
|
}
|
|
#[test]
|
|
fn execute_runtime_with_local_metalink_fixture_expands_multiple_files_and_uses_implied_output_names()
|
|
{
|
|
let mut downloader = FixtureHttpDownloader::new();
|
|
downloader.register("http://example.com/alpha.bin", b"abc");
|
|
downloader.register("http://example.com/beta.bin", b"hello");
|
|
|
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-multifile-test");
|
|
let _ = fs::create_dir_all(&temp_dir);
|
|
let download_dir = temp_dir.join("downloads");
|
|
let metalink_path = temp_dir.join("fixture.meta4");
|
|
let config_path = temp_dir.join("aria2.conf");
|
|
fs::write(
|
|
&metalink_path,
|
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
|
<metalink version="4.0">
|
|
<file name="alpha.bin">
|
|
<hash type="md5">900150983cd24fb0d6963f7d28e17f72</hash>
|
|
<url priority="1">http://example.com/alpha.bin</url>
|
|
</file>
|
|
<file name="beta.bin">
|
|
<url priority="1">http://example.com/beta.bin</url>
|
|
</file>
|
|
</metalink>"#,
|
|
)
|
|
.expect("metalink file should write");
|
|
fs::write(&config_path, format!("dir={}\n", download_dir.display()))
|
|
.expect("config should write");
|
|
|
|
let report = execute_runtime_with_downloader(
|
|
Invocation::Run {
|
|
config_path: Some(config_path),
|
|
uris: vec![metalink_path.to_string_lossy().into_owned()],
|
|
},
|
|
&downloader,
|
|
)
|
|
.expect("runtime should execute with expanded metalink fixture");
|
|
|
|
assert_eq!(report.accepted_uri_count, 2);
|
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
|
assert_eq!(report.first_total_length, Some(3));
|
|
assert_eq!(report.first_completed_length, Some(3));
|
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
|
|
assert_eq!(report.completed_download_count, 2);
|
|
assert_eq!(
|
|
fs::read(download_dir.join("alpha.bin")).expect("alpha output should exist"),
|
|
b"abc"
|
|
);
|
|
assert_eq!(
|
|
fs::read(download_dir.join("beta.bin")).expect("beta output should exist"),
|
|
b"hello"
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(temp_dir);
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct SequencedHttpDownloader {
|
|
responses: Arc<Mutex<Vec<Result<HttpResponseModel, TransportError>>>>,
|
|
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
|
|
}
|
|
|
|
struct LocalFtpTestServer {
|
|
control_port: u16,
|
|
join: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl LocalFtpTestServer {
|
|
fn spawn(payload: Vec<u8>) -> Self {
|
|
let control_listener =
|
|
TcpListener::bind("127.0.0.1:0").expect("control listener should bind");
|
|
let control_port = control_listener
|
|
.local_addr()
|
|
.expect("control addr should exist")
|
|
.port();
|
|
let data_listener = TcpListener::bind("127.0.0.1:0").expect("data listener should bind");
|
|
let data_addr = data_listener.local_addr().expect("data addr should exist");
|
|
let join = thread::spawn(move || {
|
|
let (mut control_stream, _) = control_listener
|
|
.accept()
|
|
.expect("control connection should arrive");
|
|
control_stream
|
|
.write_all(b"220 local ftp ready\r\n")
|
|
.expect("welcome should write");
|
|
let mut control_reader =
|
|
BufReader::new(control_stream.try_clone().expect("clone should work"));
|
|
loop {
|
|
let mut line = String::new();
|
|
let read = control_reader
|
|
.read_line(&mut line)
|
|
.expect("control line should read");
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
if line.starts_with("USER ") {
|
|
control_stream
|
|
.write_all(b"331 password required\r\n")
|
|
.expect("USER response should write");
|
|
} else if line.starts_with("PASS ") {
|
|
control_stream
|
|
.write_all(b"230 login ok\r\n")
|
|
.expect("PASS response should write");
|
|
} else if line.starts_with("TYPE ") {
|
|
control_stream
|
|
.write_all(b"200 type ok\r\n")
|
|
.expect("TYPE response should write");
|
|
} else if line.starts_with("PASV") {
|
|
let port_hi = data_addr.port().div_euclid(256);
|
|
let port_lo = data_addr.port().rem_euclid(256);
|
|
let response =
|
|
format!("227 Entering Passive Mode (127,0,0,1,{port_hi},{port_lo})\r\n");
|
|
control_stream
|
|
.write_all(response.as_bytes())
|
|
.expect("PASV response should write");
|
|
} else if line.starts_with("RETR ") {
|
|
control_stream
|
|
.write_all(b"150 opening data\r\n")
|
|
.expect("RETR prelim response should write");
|
|
let (mut data_stream, _) = data_listener
|
|
.accept()
|
|
.expect("data connection should arrive");
|
|
data_stream
|
|
.write_all(&payload)
|
|
.expect("payload should write");
|
|
drop(data_stream);
|
|
control_stream
|
|
.write_all(b"226 transfer complete\r\n")
|
|
.expect("RETR completion should write");
|
|
} else if line.starts_with("QUIT") {
|
|
control_stream
|
|
.write_all(b"221 bye\r\n")
|
|
.expect("QUIT response should write");
|
|
break;
|
|
} else {
|
|
control_stream
|
|
.write_all(b"500 unsupported\r\n")
|
|
.expect("fallback response should write");
|
|
}
|
|
}
|
|
});
|
|
Self {
|
|
control_port,
|
|
join: Some(join),
|
|
}
|
|
}
|
|
|
|
fn control_port(&self) -> u16 {
|
|
self.control_port
|
|
}
|
|
|
|
fn join(mut self) {
|
|
if let Some(join) = self.join.take() {
|
|
join.join().expect("ftp server thread should join");
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LiveFtpSmokeDownloader;
|
|
struct LiveSftpSmokeDownloader;
|
|
|
|
#[derive(Debug)]
|
|
struct LocalSftpDockerServer {
|
|
container_name: String,
|
|
port: u16,
|
|
payload_len: usize,
|
|
}
|
|
|
|
impl LocalSftpDockerServer {
|
|
const DOCKER_EXEC_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const DOCKER_RM_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const DOCKER_RUN_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
fn spawn() -> Option<Self> {
|
|
let docker = Command::new("docker")
|
|
.arg("--version")
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.ok()?;
|
|
if !docker.success() {
|
|
return None;
|
|
}
|
|
let port = Self::pick_port();
|
|
let container_name = format!("aria2-rust-pro-sftp-smoke-{port}");
|
|
let payload = "hello-from-live-sftp\n";
|
|
let payload_len = payload.len();
|
|
let port_binding = format!("{port}:22");
|
|
let run = Self::docker_status_with_timeout(
|
|
&[
|
|
"run",
|
|
"--rm",
|
|
"-d",
|
|
"--name",
|
|
&container_name,
|
|
"-p",
|
|
&port_binding,
|
|
"atmoz/sftp:debian",
|
|
"foo:pass:1001::upload",
|
|
],
|
|
Self::DOCKER_RUN_TIMEOUT,
|
|
)?;
|
|
if !run.success() {
|
|
return None;
|
|
}
|
|
for _ in 0..20 {
|
|
if Self::docker_status_with_timeout(
|
|
&[
|
|
"exec",
|
|
&container_name,
|
|
"sh",
|
|
"-lc",
|
|
"echo 'hello-from-live-sftp' > /home/foo/upload/hello.txt",
|
|
],
|
|
Self::DOCKER_EXEC_TIMEOUT,
|
|
)
|
|
.is_some_and(|status| status.success())
|
|
{
|
|
if Self::wait_until_ready(port) {
|
|
return Some(Self {
|
|
container_name,
|
|
port,
|
|
payload_len,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
let _ = Self::docker_status_with_timeout(
|
|
&["rm", "-f", &container_name],
|
|
Self::DOCKER_RM_TIMEOUT,
|
|
);
|
|
None
|
|
}
|
|
|
|
fn pick_port() -> u16 {
|
|
TcpListener::bind("127.0.0.1:0")
|
|
.expect("test should pick local port")
|
|
.local_addr()
|
|
.expect("local addr should exist")
|
|
.port()
|
|
}
|
|
|
|
fn host_port(&self) -> u16 {
|
|
self.port
|
|
}
|
|
|
|
fn payload_len(&self) -> usize {
|
|
self.payload_len
|
|
}
|
|
|
|
fn wait_until_ready(port: u16) -> bool {
|
|
for _ in 0..40 {
|
|
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)) {
|
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
|
|
if let Ok(mut session) = ssh2::Session::new() {
|
|
session.set_tcp_stream(stream);
|
|
if session.handshake().is_ok()
|
|
&& session.userauth_password("foo", "pass").is_ok()
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
false
|
|
}
|
|
|
|
fn docker_status_with_timeout(args: &[&str], timeout: Duration) -> Option<ExitStatus> {
|
|
let mut child = Command::new("docker")
|
|
.args(args)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.ok()?;
|
|
Self::wait_child_with_timeout(&mut child, timeout)
|
|
}
|
|
|
|
fn wait_child_with_timeout(child: &mut Child, timeout: Duration) -> Option<ExitStatus> {
|
|
let started_at = Instant::now();
|
|
loop {
|
|
if let Some(status) = child.try_wait().ok()? {
|
|
return Some(status);
|
|
}
|
|
if started_at.elapsed() >= timeout {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return None;
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for LocalSftpDockerServer {
|
|
fn drop(&mut self) {
|
|
let _ = Self::docker_status_with_timeout(
|
|
&["rm", "-f", &self.container_name],
|
|
Self::DOCKER_RM_TIMEOUT,
|
|
);
|
|
}
|
|
}
|
|
|
|
impl LiveFtpSmokeDownloader {
|
|
#[expect(
|
|
clippy::result_large_err,
|
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
|
)]
|
|
fn read_response_line(reader: &mut BufReader<TcpStream>) -> Result<String, TransportError> {
|
|
let mut line = String::new();
|
|
reader
|
|
.read_line(&mut line)
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: format!("failed to read ftp response: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
Ok(line)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::result_large_err,
|
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
|
)]
|
|
fn expect_code(
|
|
reader: &mut BufReader<TcpStream>,
|
|
expected: u16,
|
|
) -> Result<String, TransportError> {
|
|
let line = Self::read_response_line(reader)?;
|
|
let code = line
|
|
.get(0..3)
|
|
.and_then(|digits| digits.parse::<u16>().ok())
|
|
.ok_or_else(|| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("invalid ftp response line: {line:?}"),
|
|
source: None,
|
|
context: None,
|
|
})?;
|
|
if code != expected {
|
|
return Err(TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("expected ftp code {expected}, got {code}: {line}"),
|
|
source: None,
|
|
context: None,
|
|
});
|
|
}
|
|
Ok(line)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::result_large_err,
|
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
|
)]
|
|
fn write_command(stream: &mut TcpStream, command: &str) -> Result<(), TransportError> {
|
|
stream
|
|
.write_all(command.as_bytes())
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: format!("failed to write ftp command {command:?}: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
#[expect(
|
|
clippy::result_large_err,
|
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
|
)]
|
|
fn parse_pasv_addr(line: &str) -> Result<(String, u16), TransportError> {
|
|
let (_, after_open) = line.split_once('(').ok_or_else(|| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("missing PASV tuple in response: {line}"),
|
|
source: None,
|
|
context: None,
|
|
})?;
|
|
let (tuple_text, _) = after_open.split_once(')').ok_or_else(|| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("unterminated PASV tuple in response: {line}"),
|
|
source: None,
|
|
context: None,
|
|
})?;
|
|
let parts = tuple_text
|
|
.split(',')
|
|
.map(str::trim)
|
|
.map(str::parse::<u16>)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("invalid PASV tuple in response: {line}: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let [a, b, c, d, hi, lo]: [u16; 6] =
|
|
parts.try_into().map_err(|parts: Vec<u16>| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("expected 6 PASV tuple parts, got {}: {line}", parts.len()),
|
|
source: None,
|
|
context: None,
|
|
})?;
|
|
let host = format!("{a}.{b}.{c}.{d}");
|
|
let port = hi
|
|
.checked_mul(256)
|
|
.and_then(|value| value.checked_add(lo))
|
|
.ok_or_else(|| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("invalid PASV port tuple in response: {line}"),
|
|
source: None,
|
|
context: None,
|
|
})?;
|
|
Ok((host, port))
|
|
}
|
|
}
|
|
|
|
impl Downloader for LiveFtpSmokeDownloader {
|
|
fn start_http_transfer(
|
|
&self,
|
|
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
|
) -> Result<HttpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "http unused in live ftp smoke".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
fn start_ftp_transfer(
|
|
&self,
|
|
config: &FtpConfigModel,
|
|
request: &FtpRequestModel,
|
|
) -> Result<FtpResponseModel, TransportError> {
|
|
let mut control_stream =
|
|
TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
|
|
TransportError {
|
|
kind: TransportErrorKind::NotConnected,
|
|
message: format!("failed to connect to live ftp smoke server: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
}
|
|
})?;
|
|
control_stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.expect("read timeout should set");
|
|
control_stream
|
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
|
.expect("write timeout should set");
|
|
let mut control_reader =
|
|
BufReader::new(control_stream.try_clone().expect("clone should work"));
|
|
|
|
let _ = Self::expect_code(&mut control_reader, 220)?;
|
|
Self::write_command(
|
|
&mut control_stream,
|
|
&format!(
|
|
"USER {}\r\n",
|
|
config.username.as_deref().unwrap_or("anonymous")
|
|
),
|
|
)?;
|
|
let _ = Self::expect_code(&mut control_reader, 331)?;
|
|
Self::write_command(
|
|
&mut control_stream,
|
|
&format!("PASS {}\r\n", config.password.as_deref().unwrap_or("")),
|
|
)?;
|
|
let _ = Self::expect_code(&mut control_reader, 230)?;
|
|
Self::write_command(&mut control_stream, "TYPE I\r\n")?;
|
|
let _ = Self::expect_code(&mut control_reader, 200)?;
|
|
Self::write_command(&mut control_stream, "PASV\r\n")?;
|
|
let pasv = Self::expect_code(&mut control_reader, 227)?;
|
|
let (data_host, data_port) = Self::parse_pasv_addr(&pasv)?;
|
|
let mut data_stream =
|
|
TcpStream::connect((data_host.as_str(), data_port)).map_err(|error| {
|
|
TransportError {
|
|
kind: TransportErrorKind::NotConnected,
|
|
message: format!("failed to connect ftp data socket: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
}
|
|
})?;
|
|
let retr_path = request.path.as_deref().unwrap_or("/file.bin");
|
|
Self::write_command(&mut control_stream, &format!("RETR {retr_path}\r\n"))?;
|
|
let _ = Self::expect_code(&mut control_reader, 150)?;
|
|
let mut payload = Vec::new();
|
|
data_stream
|
|
.read_to_end(&mut payload)
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: format!("failed to read ftp data payload: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let completion = Self::expect_code(&mut control_reader, 226)?;
|
|
let _ = Self::write_command(&mut control_stream, "QUIT\r\n");
|
|
|
|
Ok(FtpResponseModel {
|
|
code: 226,
|
|
message: completion.trim().to_owned(),
|
|
data: Some(payload),
|
|
path: request.path.clone(),
|
|
transferable: true,
|
|
})
|
|
}
|
|
|
|
fn start_sftp_transfer(
|
|
&self,
|
|
_config: &SftpConfigModel,
|
|
_request: &SftpRequestModel,
|
|
) -> Result<SftpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "sftp unused in live ftp smoke".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Downloader for LiveSftpSmokeDownloader {
|
|
fn start_http_transfer(
|
|
&self,
|
|
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
|
) -> Result<HttpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "http unused in live sftp smoke".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
fn start_ftp_transfer(
|
|
&self,
|
|
_config: &FtpConfigModel,
|
|
_request: &FtpRequestModel,
|
|
) -> Result<FtpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "ftp unused in live sftp smoke".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
fn start_sftp_transfer(
|
|
&self,
|
|
config: &SftpConfigModel,
|
|
request: &SftpRequestModel,
|
|
) -> Result<SftpResponseModel, TransportError> {
|
|
let tcp = TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
|
|
TransportError {
|
|
kind: TransportErrorKind::NotConnected,
|
|
message: format!("failed to connect live sftp smoke server: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
}
|
|
})?;
|
|
tcp.set_read_timeout(Some(Duration::from_secs(8))).ok();
|
|
tcp.set_write_timeout(Some(Duration::from_secs(8))).ok();
|
|
|
|
let mut session = ssh2::Session::new().map_err(|error| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("failed to build ssh2 session: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
session.set_tcp_stream(tcp);
|
|
session.handshake().map_err(|error| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("ssh handshake failed: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let user = config.username.as_deref().unwrap_or("foo");
|
|
let pass = config.password.as_deref().unwrap_or("pass");
|
|
session
|
|
.userauth_password(user, pass)
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("ssh auth failed: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let sftp = session.sftp().map_err(|error| TransportError {
|
|
kind: TransportErrorKind::ProtocolViolation,
|
|
message: format!("failed to start sftp subsystem: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let path = request.path.as_deref().unwrap_or("/upload/hello.txt");
|
|
let mut remote_file = sftp.open(path).map_err(|error| TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: format!("failed to open remote path {path}: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
let mut payload = Vec::new();
|
|
remote_file
|
|
.read_to_end(&mut payload)
|
|
.map_err(|error| TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: format!("failed to read remote payload: {error}"),
|
|
source: Some(error.to_string()),
|
|
context: None,
|
|
})?;
|
|
|
|
Ok(SftpResponseModel {
|
|
ok: true,
|
|
message: "sftp read ok".to_owned(),
|
|
payload: Some(payload),
|
|
path: request.path.clone(),
|
|
transferable: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SequencedHttpDownloader {
|
|
fn new(responses: Vec<Result<HttpResponseModel, TransportError>>) -> Self {
|
|
Self {
|
|
responses: Arc::new(Mutex::new(responses)),
|
|
requests: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
|
|
self.requests.lock().expect("lock should work").clone()
|
|
}
|
|
}
|
|
|
|
impl Downloader for SequencedHttpDownloader {
|
|
fn start_http_transfer(
|
|
&self,
|
|
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
|
) -> Result<HttpResponseModel, TransportError> {
|
|
self.requests
|
|
.lock()
|
|
.expect("lock should work")
|
|
.push(task.clone());
|
|
let mut guard = self.responses.lock().expect("lock should work");
|
|
if guard.is_empty() {
|
|
return Err(TransportError {
|
|
kind: TransportErrorKind::Io,
|
|
message: "no scripted HTTP response remaining".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
});
|
|
}
|
|
guard.remove(0)
|
|
}
|
|
|
|
fn start_ftp_transfer(
|
|
&self,
|
|
_config: &FtpConfigModel,
|
|
_request: &FtpRequestModel,
|
|
) -> Result<FtpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "unused in test".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
fn start_sftp_transfer(
|
|
&self,
|
|
_config: &SftpConfigModel,
|
|
_request: &SftpRequestModel,
|
|
) -> Result<SftpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "unused in test".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ConcurrentProbeDownloader {
|
|
bootstrap: HttpResponseModel,
|
|
bootstrap_delay: Duration,
|
|
ranged_responses: Arc<Mutex<VecDeque<(u64, HttpResponseModel)>>>,
|
|
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
|
|
active_calls: Arc<AtomicUsize>,
|
|
max_concurrent_calls: Arc<AtomicUsize>,
|
|
ranged_delay: Duration,
|
|
}
|
|
|
|
impl ConcurrentProbeDownloader {
|
|
fn new(
|
|
bootstrap: HttpResponseModel,
|
|
ranged_responses: Vec<(u64, HttpResponseModel)>,
|
|
ranged_delay: Duration,
|
|
) -> Self {
|
|
Self {
|
|
bootstrap,
|
|
bootstrap_delay: Duration::ZERO,
|
|
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
|
|
requests: Arc::new(Mutex::new(Vec::new())),
|
|
active_calls: Arc::new(AtomicUsize::new(0)),
|
|
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
|
|
ranged_delay,
|
|
}
|
|
}
|
|
|
|
fn with_bootstrap_delay(
|
|
bootstrap: HttpResponseModel,
|
|
bootstrap_delay: Duration,
|
|
ranged_responses: Vec<(u64, HttpResponseModel)>,
|
|
ranged_delay: Duration,
|
|
) -> Self {
|
|
Self {
|
|
bootstrap,
|
|
bootstrap_delay,
|
|
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
|
|
requests: Arc::new(Mutex::new(Vec::new())),
|
|
active_calls: Arc::new(AtomicUsize::new(0)),
|
|
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
|
|
ranged_delay,
|
|
}
|
|
}
|
|
|
|
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
|
|
self.requests.lock().expect("lock should work").clone()
|
|
}
|
|
|
|
fn max_concurrent_calls(&self) -> usize {
|
|
self.max_concurrent_calls.load(Ordering::SeqCst)
|
|
}
|
|
|
|
fn note_active_call(&self) {
|
|
let current = self
|
|
.active_calls
|
|
.fetch_add(1, Ordering::SeqCst)
|
|
.saturating_add(1);
|
|
let _ =
|
|
self.max_concurrent_calls
|
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seen| {
|
|
(current > seen).then_some(current)
|
|
});
|
|
}
|
|
|
|
fn finish_active_call(&self) {
|
|
self.active_calls.fetch_sub(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
impl Downloader for ConcurrentProbeDownloader {
|
|
fn start_http_transfer(
|
|
&self,
|
|
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
|
) -> Result<HttpResponseModel, TransportError> {
|
|
self.requests
|
|
.lock()
|
|
.expect("lock should work")
|
|
.push(task.clone());
|
|
self.note_active_call();
|
|
|
|
let response = task.request.range.as_ref().map_or_else(
|
|
|| {
|
|
if !self.bootstrap_delay.is_zero() {
|
|
thread::sleep(self.bootstrap_delay);
|
|
}
|
|
Ok(self.bootstrap.clone())
|
|
},
|
|
|range| {
|
|
thread::sleep(self.ranged_delay);
|
|
let mut queued = self.ranged_responses.lock().expect("lock should work");
|
|
let index = queued
|
|
.iter()
|
|
.position(|(start, _)| *start == range.start)
|
|
.expect("matching ranged response should exist");
|
|
Ok(queued
|
|
.remove(index)
|
|
.expect("queued ranged response should exist")
|
|
.1)
|
|
},
|
|
);
|
|
|
|
self.finish_active_call();
|
|
response
|
|
}
|
|
|
|
fn start_ftp_transfer(
|
|
&self,
|
|
_config: &FtpConfigModel,
|
|
_request: &FtpRequestModel,
|
|
) -> Result<FtpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "probe downloader does not implement ftp".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
|
|
fn start_sftp_transfer(
|
|
&self,
|
|
_config: &SftpConfigModel,
|
|
_request: &SftpRequestModel,
|
|
) -> Result<SftpResponseModel, TransportError> {
|
|
Err(TransportError {
|
|
kind: TransportErrorKind::UnsupportedScheme,
|
|
message: "probe downloader does not implement sftp".to_owned(),
|
|
source: None,
|
|
context: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
mod command_surface;
|
|
mod integration_surface;
|
|
mod runtime_execution;
|