|
16 | 16 | use crate::credential_store::CredentialStore; |
17 | 17 | use crate::providers::{ |
18 | 18 | ProviderConfig, ProviderError, ProviderFactory, ProviderType, StorageProvider, |
| 19 | + TransferOptimizationHints, |
19 | 20 | }; |
| 21 | +use crate::transfer_dag::TransferCapabilities; |
20 | 22 | use serde_json::{json, Value}; |
21 | 23 | use std::collections::HashMap; |
22 | 24 | use std::time::Instant; |
@@ -297,6 +299,135 @@ pub fn capabilities_for_protocol(protocol: &str) -> Vec<&'static str> { |
297 | 299 | } |
298 | 300 | } |
299 | 301 |
|
| 302 | +pub fn provider_type_for_transfer_capabilities(protocol: &str) -> Option<ProviderType> { |
| 303 | + match protocol.to_ascii_lowercase().as_str() { |
| 304 | + "ftp" => Some(ProviderType::Ftp), |
| 305 | + "ftps" => Some(ProviderType::Ftps), |
| 306 | + "sftp" => Some(ProviderType::Sftp), |
| 307 | + "webdav" | "webdavs" | "web_dav" => Some(ProviderType::WebDav), |
| 308 | + "s3" => Some(ProviderType::S3), |
| 309 | + "aerocloud" | "aero_cloud" => Some(ProviderType::AeroCloud), |
| 310 | + "googledrive" | "google_drive" | "google drive" => Some(ProviderType::GoogleDrive), |
| 311 | + "googlephotos" | "google_photos" | "google photos" => Some(ProviderType::GooglePhotos), |
| 312 | + "dropbox" => Some(ProviderType::Dropbox), |
| 313 | + "onedrive" | "one_drive" | "one drive" => Some(ProviderType::OneDrive), |
| 314 | + "mega" => Some(ProviderType::Mega), |
| 315 | + "box" => Some(ProviderType::Box), |
| 316 | + "pcloud" | "p_cloud" => Some(ProviderType::PCloud), |
| 317 | + "azure" => Some(ProviderType::Azure), |
| 318 | + "filen" => Some(ProviderType::Filen), |
| 319 | + "fourshared" | "four_shared" | "4shared" => Some(ProviderType::FourShared), |
| 320 | + "zohoworkdrive" | "zoho_workdrive" | "zoho workdrive" => Some(ProviderType::ZohoWorkdrive), |
| 321 | + "internxt" => Some(ProviderType::Internxt), |
| 322 | + "kdrive" | "k_drive" => Some(ProviderType::KDrive), |
| 323 | + "jottacloud" => Some(ProviderType::Jottacloud), |
| 324 | + "drime" | "drimecloud" | "drime_cloud" => Some(ProviderType::DrimeCloud), |
| 325 | + "filelu" | "file_lu" => Some(ProviderType::FileLu), |
| 326 | + "koofr" => Some(ProviderType::Koofr), |
| 327 | + "opendrive" | "open_drive" => Some(ProviderType::OpenDrive), |
| 328 | + "yandexdisk" | "yandex_disk" | "yandex disk" => Some(ProviderType::YandexDisk), |
| 329 | + "github" => Some(ProviderType::GitHub), |
| 330 | + "gitlab" => Some(ProviderType::GitLab), |
| 331 | + "swift" => Some(ProviderType::Swift), |
| 332 | + "immich" => Some(ProviderType::Immich), |
| 333 | + "imagekit" | "image_kit" => Some(ProviderType::ImageKit), |
| 334 | + "uploadcare" | "upload_care" => Some(ProviderType::Uploadcare), |
| 335 | + "backblaze" | "b2" | "backblazeb2" | "backblaze_b2" => Some(ProviderType::Backblaze), |
| 336 | + "cloudinary" => Some(ProviderType::Cloudinary), |
| 337 | + _ => None, |
| 338 | + } |
| 339 | +} |
| 340 | + |
| 341 | +pub fn default_transfer_optimization_hints_for_provider( |
| 342 | + provider_type: ProviderType, |
| 343 | +) -> TransferOptimizationHints { |
| 344 | + match provider_type { |
| 345 | + ProviderType::Sftp => TransferOptimizationHints { |
| 346 | + supports_range_download: true, |
| 347 | + supports_compression: true, |
| 348 | + supports_delta_sync: true, |
| 349 | + ..Default::default() |
| 350 | + }, |
| 351 | + ProviderType::Ftp | ProviderType::Ftps => TransferOptimizationHints { |
| 352 | + supports_resume_download: true, |
| 353 | + supports_resume_upload: true, |
| 354 | + supports_range_download: true, |
| 355 | + ..Default::default() |
| 356 | + }, |
| 357 | + ProviderType::S3 => TransferOptimizationHints { |
| 358 | + supports_multipart: true, |
| 359 | + multipart_threshold: 5 * 1024 * 1024, |
| 360 | + multipart_part_size: 5 * 1024 * 1024, |
| 361 | + multipart_max_parallel: 4, |
| 362 | + supports_range_download: true, |
| 363 | + supports_resume_download: true, |
| 364 | + supports_server_checksum: true, |
| 365 | + preferred_checksum_algo: Some("ETag".to_string()), |
| 366 | + ..Default::default() |
| 367 | + }, |
| 368 | + ProviderType::Backblaze => TransferOptimizationHints { |
| 369 | + supports_multipart: true, |
| 370 | + multipart_threshold: 200 * 1024 * 1024, |
| 371 | + multipart_part_size: 100 * 1024 * 1024, |
| 372 | + multipart_max_parallel: 4, |
| 373 | + ..Default::default() |
| 374 | + }, |
| 375 | + ProviderType::WebDav | ProviderType::Koofr => TransferOptimizationHints { |
| 376 | + supports_range_download: true, |
| 377 | + supports_resume_download: true, |
| 378 | + ..Default::default() |
| 379 | + }, |
| 380 | + ProviderType::Azure => TransferOptimizationHints { |
| 381 | + supports_range_download: true, |
| 382 | + supports_resume_download: true, |
| 383 | + ..Default::default() |
| 384 | + }, |
| 385 | + ProviderType::Swift => TransferOptimizationHints { |
| 386 | + supports_resume_download: true, |
| 387 | + ..Default::default() |
| 388 | + }, |
| 389 | + _ => TransferOptimizationHints::default(), |
| 390 | + } |
| 391 | +} |
| 392 | + |
| 393 | +pub fn transfer_capabilities_for_protocol(protocol: &str) -> Option<TransferCapabilities> { |
| 394 | + let provider_type = provider_type_for_transfer_capabilities(protocol)?; |
| 395 | + Some(transfer_capabilities_for_provider_type( |
| 396 | + provider_type, |
| 397 | + protocol, |
| 398 | + )) |
| 399 | +} |
| 400 | + |
| 401 | +pub fn transfer_capabilities_for_provider_type( |
| 402 | + provider_type: ProviderType, |
| 403 | + protocol_for_feature_hint: &str, |
| 404 | +) -> TransferCapabilities { |
| 405 | + TransferCapabilities::from_provider_hints( |
| 406 | + provider_type, |
| 407 | + &default_transfer_optimization_hints_for_provider(provider_type), |
| 408 | + capabilities_for_protocol(protocol_for_feature_hint).contains(&"server_copy"), |
| 409 | + ) |
| 410 | +} |
| 411 | + |
| 412 | +pub fn transfer_capabilities_block( |
| 413 | + protocol: &str, |
| 414 | + live_capabilities: Option<TransferCapabilities>, |
| 415 | + source: &str, |
| 416 | +) -> Value { |
| 417 | + match live_capabilities.or_else(|| transfer_capabilities_for_protocol(protocol)) { |
| 418 | + Some(capabilities) => json!({ |
| 419 | + "status": "ok", |
| 420 | + "source": source, |
| 421 | + "capabilities": capabilities, |
| 422 | + }), |
| 423 | + None => json!({ |
| 424 | + "status": "unsupported", |
| 425 | + "source": source, |
| 426 | + "capabilities": TransferCapabilities::default(), |
| 427 | + }), |
| 428 | + } |
| 429 | +} |
| 430 | + |
300 | 431 | /// Profile block: always present, summarises which profile the agent |
301 | 432 | /// is talking about. Never carries `status` (it's metadata, not a step |
302 | 433 | /// that can fail). |
@@ -331,6 +462,11 @@ pub fn capabilities_block(protocol: &str) -> Value { |
331 | 462 | json!({ |
332 | 463 | "status": "ok", |
333 | 464 | "features": features, |
| 465 | + "transfer_capabilities": transfer_capabilities_block( |
| 466 | + protocol, |
| 467 | + None, |
| 468 | + "protocol_defaults" |
| 469 | + ), |
334 | 470 | }) |
335 | 471 | } |
336 | 472 |
|
@@ -527,14 +663,19 @@ pub async fn build_agent_connect_payload(query: &str) -> Value { |
527 | 663 | }; |
528 | 664 |
|
529 | 665 | let path = path_block(&profile); |
530 | | - let capabilities = capabilities_block(&profile.protocol); |
| 666 | + let mut capabilities = capabilities_block(&profile.protocol); |
531 | 667 |
|
532 | 668 | let connect_started = Instant::now(); |
533 | 669 | let connect_result = connect_provider(&profile).await; |
534 | 670 | let elapsed_ms = connect_started.elapsed().as_millis(); |
535 | 671 |
|
536 | 672 | let (connect, quota) = match connect_result { |
537 | 673 | ConnectOutcome::Connected(mut provider) => { |
| 674 | + capabilities["transfer_capabilities"] = transfer_capabilities_block( |
| 675 | + &profile.protocol, |
| 676 | + Some(provider.transfer_capabilities()), |
| 677 | + "live_provider", |
| 678 | + ); |
538 | 679 | let connect = connect_block_ok(&profile.id, elapsed_ms); |
539 | 680 | let quota = match provider.storage_info().await { |
540 | 681 | Ok(info) => quota_block_ok(info.used, info.total, info.free), |
@@ -614,6 +755,36 @@ mod tests { |
614 | 755 | assert!(capabilities_for_protocol("xyzzy").is_empty()); |
615 | 756 | } |
616 | 757 |
|
| 758 | + #[test] |
| 759 | + fn transfer_capabilities_block_exposes_parallel_limits() { |
| 760 | + let v = transfer_capabilities_block("backblaze", None, "profile_defaults"); |
| 761 | + assert_eq!(v["status"], "ok"); |
| 762 | + assert_eq!(v["source"], "profile_defaults"); |
| 763 | + assert_eq!(v["capabilities"]["multipart_upload"], "supported"); |
| 764 | + assert_eq!(v["capabilities"]["max_chunk_slots"], 4); |
| 765 | + assert_eq!(v["capabilities"]["preferred_chunk_size"], 100 * 1024 * 1024); |
| 766 | + } |
| 767 | + |
| 768 | + #[test] |
| 769 | + fn transfer_capabilities_unknown_protocol_is_explicit() { |
| 770 | + let v = transfer_capabilities_block("xyzzy", None, "profile_defaults"); |
| 771 | + assert_eq!(v["status"], "unsupported"); |
| 772 | + assert_eq!(v["source"], "profile_defaults"); |
| 773 | + assert_eq!(v["capabilities"]["max_file_slots"], 1); |
| 774 | + } |
| 775 | + |
| 776 | + #[test] |
| 777 | + fn capabilities_block_contains_transfer_capabilities() { |
| 778 | + let v = capabilities_block("sftp"); |
| 779 | + assert_eq!(v["status"], "ok"); |
| 780 | + assert_eq!(v["transfer_capabilities"]["status"], "ok"); |
| 781 | + assert_eq!(v["transfer_capabilities"]["source"], "protocol_defaults"); |
| 782 | + assert_eq!( |
| 783 | + v["transfer_capabilities"]["capabilities"]["strict_concurrent_range_download"], |
| 784 | + "unsupported" |
| 785 | + ); |
| 786 | + } |
| 787 | + |
617 | 788 | #[test] |
618 | 789 | fn block_helpers_carry_status() { |
619 | 790 | // Status is the agent's primary read: make sure each helper |
|
0 commit comments