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 { fn bencode_bytes(value: &[u8]) -> Vec { let mut encoded = format!("{}:", value.len()).into_bytes(); encoded.extend_from_slice(value); encoded } fn bencode_int(value: u64) -> Vec { 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#" 900150983cd24fb0d6963f7d28e17f72 http://example.com/alpha.bin http://example.com/beta.bin "#, ) .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>>>, requests: Arc>>, } struct LocalFtpTestServer { control_port: u16, join: Option>, } impl LocalFtpTestServer { fn spawn(payload: Vec) -> 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 { 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 { 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 { 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) -> Result { 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, expected: u16, ) -> Result { let line = Self::read_response_line(reader)?; let code = line .get(0..3) .and_then(|digits| digits.parse::().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::) .collect::, _>>() .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| 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 { 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 { 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 { 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 { 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 { 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 { 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>) -> Self { Self { responses: Arc::new(Mutex::new(responses)), requests: Arc::new(Mutex::new(Vec::new())), } } fn recorded_requests(&self) -> Vec { self.requests.lock().expect("lock should work").clone() } } impl Downloader for SequencedHttpDownloader { fn start_http_transfer( &self, task: &aria2_rust_pro_protocol::HttpTransferTaskModel, ) -> Result { 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 { 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 { 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>>, requests: Arc>>, active_calls: Arc, max_concurrent_calls: Arc, 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 { 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 { 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 { 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 { 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;