From 503577d6d36006c5a65d2161ecf2698ef32adbef Mon Sep 17 00:00:00 2001 From: luke miles Date: Wed, 26 Aug 2026 00:48:33 -0400 Subject: [PATCH 1/3] fix: preserve TP-7 MTP mode across commands --- Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 18 +- docs/spec.md | 8 +- docs/tp7-handshake.md | 27 +- src/cli.rs | 22 + src/connect.rs | 6 +- src/device.rs | 20 +- src/eject.rs | 2 +- src/lib.rs | 1 + src/ls.rs | 21 +- src/mtp_session.rs | 122 +- src/output.rs | 5 +- src/pull.rs | 4 +- src/push.rs | 4 +- src/stat.rs | 4 +- src/status.rs | 2 +- src/tree.rs | 4 +- src/usb_owner.rs | 55 +- src/write_ops.rs | 12 +- vendor/mtp-rs/Cargo.toml | 154 ++ vendor/mtp-rs/LICENSE-APACHE | 201 +++ vendor/mtp-rs/LICENSE-MIT | 21 + vendor/mtp-rs/README.md | 466 ++++++ vendor/mtp-rs/TP7-PATCH.md | 12 + vendor/mtp-rs/src/error.rs | 176 +++ vendor/mtp-rs/src/lib.rs | 69 + vendor/mtp-rs/src/mtp/device.rs | 728 ++++++++++ vendor/mtp-rs/src/mtp/event.rs | 182 +++ vendor/mtp-rs/src/mtp/mod.rs | 45 + vendor/mtp-rs/src/mtp/object.rs | 194 +++ vendor/mtp-rs/src/mtp/storage.rs | 1112 ++++++++++++++ vendor/mtp-rs/src/mtp/stream.rs | 300 ++++ vendor/mtp-rs/src/ptp/codes.rs | 660 +++++++++ vendor/mtp-rs/src/ptp/container.rs | 724 +++++++++ vendor/mtp-rs/src/ptp/device.rs | 268 ++++ vendor/mtp-rs/src/ptp/mod.rs | 155 ++ vendor/mtp-rs/src/ptp/pack/datetime.rs | 549 +++++++ vendor/mtp-rs/src/ptp/pack/mod.rs | 534 +++++++ vendor/mtp-rs/src/ptp/session/mod.rs | 889 ++++++++++++ vendor/mtp-rs/src/ptp/session/operations.rs | 798 ++++++++++ vendor/mtp-rs/src/ptp/session/properties.rs | 354 +++++ vendor/mtp-rs/src/ptp/session/streaming.rs | 657 +++++++++ vendor/mtp-rs/src/ptp/test_utils.rs | 82 ++ vendor/mtp-rs/src/ptp/types/device.rs | 513 +++++++ vendor/mtp-rs/src/ptp/types/mod.rs | 21 + vendor/mtp-rs/src/ptp/types/objects.rs | 595 ++++++++ vendor/mtp-rs/src/ptp/types/properties.rs | 772 ++++++++++ vendor/mtp-rs/src/ptp/types/storage.rs | 243 ++++ vendor/mtp-rs/src/transport/mock.rs | 301 ++++ vendor/mtp-rs/src/transport/mod.rs | 72 + vendor/mtp-rs/src/transport/nusb.rs | 777 ++++++++++ .../src/transport/virtual_device/CLAUDE.md | 48 + .../src/transport/virtual_device/builders.rs | 260 ++++ .../src/transport/virtual_device/config.rs | 69 + .../src/transport/virtual_device/handlers.rs | 1096 ++++++++++++++ .../src/transport/virtual_device/mod.rs | 1290 +++++++++++++++++ .../src/transport/virtual_device/registry.rs | 378 +++++ .../src/transport/virtual_device/state.rs | 367 +++++ .../src/transport/virtual_device/watcher.rs | 239 +++ 60 files changed, 16665 insertions(+), 51 deletions(-) create mode 100644 vendor/mtp-rs/Cargo.toml create mode 100644 vendor/mtp-rs/LICENSE-APACHE create mode 100644 vendor/mtp-rs/LICENSE-MIT create mode 100644 vendor/mtp-rs/README.md create mode 100644 vendor/mtp-rs/TP7-PATCH.md create mode 100644 vendor/mtp-rs/src/error.rs create mode 100644 vendor/mtp-rs/src/lib.rs create mode 100644 vendor/mtp-rs/src/mtp/device.rs create mode 100644 vendor/mtp-rs/src/mtp/event.rs create mode 100644 vendor/mtp-rs/src/mtp/mod.rs create mode 100644 vendor/mtp-rs/src/mtp/object.rs create mode 100644 vendor/mtp-rs/src/mtp/storage.rs create mode 100644 vendor/mtp-rs/src/mtp/stream.rs create mode 100644 vendor/mtp-rs/src/ptp/codes.rs create mode 100644 vendor/mtp-rs/src/ptp/container.rs create mode 100644 vendor/mtp-rs/src/ptp/device.rs create mode 100644 vendor/mtp-rs/src/ptp/mod.rs create mode 100644 vendor/mtp-rs/src/ptp/pack/datetime.rs create mode 100644 vendor/mtp-rs/src/ptp/pack/mod.rs create mode 100644 vendor/mtp-rs/src/ptp/session/mod.rs create mode 100644 vendor/mtp-rs/src/ptp/session/operations.rs create mode 100644 vendor/mtp-rs/src/ptp/session/properties.rs create mode 100644 vendor/mtp-rs/src/ptp/session/streaming.rs create mode 100644 vendor/mtp-rs/src/ptp/test_utils.rs create mode 100644 vendor/mtp-rs/src/ptp/types/device.rs create mode 100644 vendor/mtp-rs/src/ptp/types/mod.rs create mode 100644 vendor/mtp-rs/src/ptp/types/objects.rs create mode 100644 vendor/mtp-rs/src/ptp/types/properties.rs create mode 100644 vendor/mtp-rs/src/ptp/types/storage.rs create mode 100644 vendor/mtp-rs/src/transport/mock.rs create mode 100644 vendor/mtp-rs/src/transport/mod.rs create mode 100644 vendor/mtp-rs/src/transport/nusb.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md create mode 100644 vendor/mtp-rs/src/transport/virtual_device/builders.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/config.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/handlers.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/mod.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/registry.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/state.rs create mode 100644 vendor/mtp-rs/src/transport/virtual_device/watcher.rs diff --git a/Cargo.lock b/Cargo.lock index 1662cd3..7a76679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,8 +410,6 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mtp-rs" version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7c57cd0f4a0371556db22a3a532eb6ab82b90979e53d7ba119d4a1156faf3f" dependencies = [ "async-trait", "bytes", @@ -697,7 +695,7 @@ dependencies = [ [[package]] name = "tp7" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytes", "clap", diff --git a/Cargo.toml b/Cargo.toml index fb05693..a70fb9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tp7" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.88" authors = ["Toto Tvalavadze"] @@ -18,7 +18,7 @@ clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" futures = "0.3" log = "0.4.29" -mtp-rs = "0.13.3" +mtp-rs = { path = "vendor/mtp-rs" } nusb = "0.2.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.md b/README.md index 12ace47..cd4bae9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ tp7 -a push ./clip.wav /memo/clip.wav --overwrite tp7 -a rm /memo/clip.wav --dry-run ``` -For normal use, prefer `-a` / `--auto-connect`. Each command then handles the full TP-7 lifecycle: detect the recorder, switch to MTP if needed, open MTP, do the operation, and close cleanly. +For normal use, prefer `-a` / `--auto-connect`. Each command detects the +recorder, switches to MTP if needed, opens or reuses the MTP session, performs +the operation, and leaves the recorder ready for the next command. `tp7 connect` and `tp7 eject` are diagnostic/manual-control commands. They are useful for checking whether MTP can be opened and released, but they are not a "mount once, run many commands, eject later" workflow. @@ -63,6 +65,20 @@ tp7 -a ls -sS --human-readable / tp7 -a ls -ltr /recordings ``` +TP-7 file commands leave the recorder in MTP mode after they exit, so repeated +commands work without reconnecting or a resident helper process. `tp7 eject` +is the explicit way to close the device-side MTP session. On macOS, +`ptpcamerad` may claim the interface before `tp7`; when that happens, +`--take-over` can reclaim it and retry: + +```sh +tp7 -a ls --take-over -lah / +``` + +`--take-over` only terminates a process when macOS reports `ptpcamerad` as the +exclusive owner of the TP-7 MTP interface. It does not terminate FieldKit, +OpenMTP, or unrecognized owners. + ## Agent-friendly by design `tp7` follows the same Project Humane conventions as the other local tools in this workspace: concise text output for people, deterministic `--json` for scripts and agents, non-zero exit codes on errors, explicit destructive commands, and dry-run support where a command can change files. diff --git a/docs/spec.md b/docs/spec.md index be442fd..1f773d0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,7 +29,10 @@ Device identity: - Product: `TP-7` - Vendor string: `teenage engineering` - Vendor ID: `0x2367` -- Product ID: `0x0019` +- Product IDs: `0x0019` in the original observation; `0x8019` for the + audio/MIDI personality on another observed unit. The latter re-enumerates as + `0x0019` in MTP mode, so interface descriptors remain the authoritative mode + signal. - Serial: `F1RTL11C` - USB speed: high speed, 480 Mbps - USB version: 2.0 @@ -280,6 +283,9 @@ Useful flags: - `-t, --sort-time`: sort by modified time, newest first. - `-r, --reverse`: reverse listing order. - `-i, --ids`: show MTP object IDs. +- `--take-over`: after an exclusive-access failure, terminate and retry only + when macOS reports `ptpcamerad` as the exclusive owner of the TP-7 MTP + interface. Other owners remain untouched. - `--json` `tp7 tree [remote-path]` diff --git a/docs/tp7-handshake.md b/docs/tp7-handshake.md index 99e1a91..946d41a 100644 --- a/docs/tp7-handshake.md +++ b/docs/tp7-handshake.md @@ -8,7 +8,8 @@ mode into MTP mode, and how we reverse-engineered the sequence. The TP-7 does not expose MTP as a normal macOS mountable device when first plugged in. It starts as a USB audio/MIDI device. To access files, the CLI must: -1. Find the TP-7 over USB and CoreMIDI. +1. Find the TP-7 over USB and CoreMIDI. The observed audio/MIDI personality + uses product ID `0x8019`; MTP uses `0x0019`. 2. Send a Teenage Engineering SysEx `greet` command over MIDI. 3. Send a Teenage Engineering SysEx `mode` command over MIDI. 4. Wait for the TP-7 to re-enumerate as `TP-7 MTP Device`. @@ -28,7 +29,7 @@ sequenceDiagram participant TP7 as TP-7 participant MTP as MTP session - CLI->>USB: Detect vendor 0x2367, product 0x0019 + CLI->>USB: Detect vendor 0x2367, product 0x8019 or 0x0019 USB-->>CLI: TP-7 in audio/MIDI mode CLI->>MIDI: Find TP-7 MIDI source and destination CLI->>TP7: Universal MIDI identity request @@ -136,8 +137,26 @@ What each step taught us: ## CLI Implications -- File commands switch to MTP when `--auto-connect` is set, open a session, - perform the operation, and close cleanly in one flow. +- File commands switch to MTP when `--auto-connect` is set, open or reuse the + durable device-side session, perform the operation, and release host ownership + without sending the TP-7 back to audio/MIDI mode. +- A TP-7 observed in August 2026 acknowledged MTP `CloseSession` and then left + USB enumeration. Omitting `CloseSession` allowed an immediate second process + to win a short race, but a timestamped trace showed the device still returned + to its `0x8019` audio/MIDI personality within two seconds of the host releasing + the USB interface. A traced FieldKit 2.0.1 session sent no periodic MTP + traffic while idle. Its stable pre-list initialization reads the battery + descriptor, refreshes storage metadata twice, reads battery level twice and + device time once, then refreshes storage metadata before listing objects. + A smaller prefix remained stable only until object listing began. The CLI now + performs the full pre-list initialization, uses FieldKit's stable session ID, + reuses `SessionAlreadyOpen` without sending `CloseSession`, and leaves USB + cleanup to process exit. Ordinary commands therefore leave the TP-7 in MTP + without polling or a resident process; `tp7 eject` remains the explicit + protocol-level close. +- `ls --take-over` automates that recovery narrowly: it acts only after an + exclusive-access failure and only when IORegistry identifies `ptpcamerad` as + the exclusive TP-7 MTP interface owner. It leaves every other owner alone. - `tp7 status` cannot assume a previous `tp7 connect` left the TP-7 in MTP mode. - The CLI should warn when FieldKit or other processes own TP-7 interfaces, but it should not require those apps. diff --git a/src/cli.rs b/src/cli.rs index 1d4d91b..5aa159c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -118,6 +118,12 @@ pub struct LsArgs { #[arg(short = 'r', long, help = "Reverse the listing order")] pub reverse: bool, + #[arg( + long, + help = "Terminate ptpcamerad only when it owns the MTP interface, then retry (macOS)" + )] + pub take_over: bool, + #[arg( short = 'h', long, @@ -129,6 +135,22 @@ pub struct LsArgs { pub help: Option, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ls_take_over() { + let cli = Cli::try_parse_from(["tp7", "ls", "--take-over", "/"]).unwrap(); + + let Command::Ls(args) = cli.command else { + panic!("expected ls command"); + }; + assert!(args.take_over); + assert_eq!(args.remote_path, "/"); + } +} + #[derive(Debug, Args)] pub struct TreeArgs { #[arg(default_value = "/", help = "Remote folder or file path")] diff --git a/src/connect.rs b/src/connect.rs index a42a264..82fd1ca 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -4,6 +4,7 @@ use crate::device::{Tp7Device, UsbMode}; use crate::midi::MidiSwitchReport; use crate::mtp_session::{ MtpOpenPolicy, PreparedMtpDevice, block_on, open_mtp_device, prepare_mtp_device, + release_mtp_device, }; use crate::output::AppError; @@ -83,10 +84,7 @@ fn validate_mtp_session(serial: Option<&str>) -> MtpSessionCheck { .await .map_err(crate::mtp_session::map_mtp_error)?; let storage_count = storages.len(); - device - .close() - .await - .map_err(crate::mtp_session::map_mtp_error)?; + release_mtp_device(device); Ok(storage_count) }) { Ok(storage_count) => MtpSessionCheck { diff --git a/src/device.rs b/src/device.rs index 3c19e2a..f67b63e 100644 --- a/src/device.rs +++ b/src/device.rs @@ -4,7 +4,12 @@ use serde::Serialize; use crate::output::AppError; pub const TP7_VENDOR_ID: u16 = 0x2367; -pub const TP7_PRODUCT_ID: u16 = 0x0019; +pub const TP7_MTP_PRODUCT_ID: u16 = 0x0019; +pub const TP7_AUDIO_MIDI_PRODUCT_ID: u16 = 0x8019; + +pub fn is_tp7_product_id(product_id: u16) -> bool { + matches!(product_id, TP7_MTP_PRODUCT_ID | TP7_AUDIO_MIDI_PRODUCT_ID) +} #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] @@ -72,7 +77,7 @@ pub fn list_tp7_devices() -> Result, AppError> { Ok(devices .filter(|device| { - device.vendor_id() == TP7_VENDOR_ID && device.product_id() == TP7_PRODUCT_ID + device.vendor_id() == TP7_VENDOR_ID && is_tp7_product_id(device.product_id()) }) .map(Tp7Device::from) .collect()) @@ -330,9 +335,9 @@ mod tests { fn tp7_device(serial_number: Option<&str>) -> Tp7Device { Tp7Device { vendor_id: TP7_VENDOR_ID, - product_id: TP7_PRODUCT_ID, + product_id: TP7_AUDIO_MIDI_PRODUCT_ID, vendor_id_hex: "0x2367".to_string(), - product_id_hex: "0x0019".to_string(), + product_id_hex: "0x8019".to_string(), manufacturer: Some("teenage engineering".to_string()), product: Some("TP-7".to_string()), serial_number: serial_number.map(str::to_string), @@ -359,6 +364,13 @@ mod tests { assert_eq!(infer_usb_mode(&interfaces), UsbMode::AudioMidi); } + #[test] + fn recognizes_audio_midi_and_mtp_product_ids() { + assert!(is_tp7_product_id(TP7_AUDIO_MIDI_PRODUCT_ID)); + assert!(is_tp7_product_id(TP7_MTP_PRODUCT_ID)); + assert!(!is_tp7_product_id(0x1234)); + } + #[test] fn infers_mtp_mode() { let interfaces = vec![interface(0x06, 0x01, 0x01)]; diff --git a/src/eject.rs b/src/eject.rs index 87981b5..7b55287 100644 --- a/src/eject.rs +++ b/src/eject.rs @@ -32,7 +32,7 @@ pub fn run_eject(serial: Option<&str>, auto_connect: bool) -> Result Result<(), AppError> { cli.device.as_deref(), cli.auto_connect, args.remote_path.as_str(), + args.take_over, )?; let sort = if args.sort_size { output::LsSort::Size diff --git a/src/ls.rs b/src/ls.rs index b8d4a42..cb347b1 100644 --- a/src/ls.rs +++ b/src/ls.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; +use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session_with_takeover}; use crate::output::AppError; use crate::remote::{RemoteObject, RemoteTarget, first_storage, list_remote_objects, resolve_path}; @@ -17,6 +17,7 @@ pub fn run_ls( serial: Option<&str>, auto_connect: bool, remote_path: &str, + take_over: bool, ) -> Result { let policy = if auto_connect { MtpOpenPolicy::AutoSwitch @@ -25,14 +26,16 @@ pub fn run_ls( }; block_on(async { - let session = open_mtp_session(serial, policy).await?; - let result = read_ls(&session.device, remote_path).await; - let close_result = session.close().await; - - match (result, close_result) { - (Ok(report), Ok(())) => Ok(report), - (Err(error), _) => Err(error), - (Ok(_), Err(error)) => Err(error), + let session = open_mtp_session_with_takeover(serial, policy, take_over).await?; + match read_ls(&session.device, remote_path).await { + Ok(report) => { + session.release().await?; + Ok(report) + } + Err(error) => { + let _ = session.release().await; + Err(error) + } } }) } diff --git a/src/mtp_session.rs b/src/mtp_session.rs index 7055ca2..a059dcc 100644 --- a/src/mtp_session.rs +++ b/src/mtp_session.rs @@ -1,16 +1,20 @@ use std::future::Future; +use std::process::Command; use std::thread; use std::time::{Duration, Instant}; use mtp_rs::MtpDevice; +use mtp_rs::ptp::DevicePropertyCode; use crate::device::{ - TP7_PRODUCT_ID, TP7_VENDOR_ID, Tp7Device, UsbMode, list_tp7_devices, select_one_device, + TP7_MTP_PRODUCT_ID, TP7_VENDOR_ID, Tp7Device, UsbMode, list_tp7_devices, select_one_device, }; use crate::midi::{MidiSwitchReport, switch_tp7_to_mtp}; use crate::output::AppError; +use crate::usb_owner::{inspect_tp7_usb_owners, ptpcamerad_exclusive_owner_pids}; -pub const KNOWN_TP7: &[(u16, u16)] = &[(TP7_VENDOR_ID, TP7_PRODUCT_ID)]; +pub const KNOWN_TP7: &[(u16, u16)] = &[(TP7_VENDOR_ID, TP7_MTP_PRODUCT_ID)]; +const TP7_SESSION_ID: u32 = 0xBAAA_AAAD; #[derive(Debug, Clone, Copy)] pub enum MtpOpenPolicy { @@ -33,11 +37,23 @@ pub struct MtpSession { } impl MtpSession { - pub async fn close(self) -> Result<(), AppError> { + /// Leave the device-side session open and let CLI process exit reclaim USB handles. + pub async fn release(self) -> Result<(), AppError> { + std::mem::forget(self); + Ok(()) + } + + /// Explicitly close the device-side session for `tp7 eject`. + pub async fn eject(self) -> Result<(), AppError> { self.device.close().await.map_err(map_mtp_error) } } +/// Release a validation connection without sending the TP-7 `CloseSession`. +pub fn release_mtp_device(device: MtpDevice) { + std::mem::forget(device); +} + pub fn block_on(future: F) -> Result where F: Future>, @@ -123,6 +139,69 @@ pub async fn open_mtp_session( open_prepared_mtp_session(prepared).await } +pub async fn open_mtp_session_with_takeover( + serial: Option<&str>, + policy: MtpOpenPolicy, + take_over: bool, +) -> Result { + let prepared = prepare_mtp_device(serial, policy)?; + let first_open = open_prepared_mtp_session(prepared.clone()).await; + + match first_open { + Err(error @ AppError::MtpExclusiveAccess { .. }) if take_over => { + if !take_over_from_ptpcamerad()? { + return Err(error); + } + + retry_open_prepared_mtp_session(prepared, Duration::from_secs(2)).await + } + result => result, + } +} + +async fn retry_open_prepared_mtp_session( + prepared: PreparedMtpDevice, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + + loop { + match open_prepared_mtp_session(prepared.clone()).await { + Err(AppError::MtpExclusiveAccess { .. }) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(100)); + } + result => return result, + } + } +} + +fn take_over_from_ptpcamerad() -> Result { + let owners = inspect_tp7_usb_owners()?; + let pids = ptpcamerad_exclusive_owner_pids(&owners); + + if pids.is_empty() { + return Ok(false); + } + + for pid in pids { + log::warn!("terminating ptpcamerad process {pid} to claim the TP-7 MTP interface"); + let status = Command::new("/bin/kill") + .args(["-KILL", &pid.to_string()]) + .status() + .map_err(|error| AppError::MtpTakeover { + message: format!("could not terminate ptpcamerad process {pid}: {error}"), + })?; + + if !status.success() { + return Err(AppError::MtpTakeover { + message: format!("could not terminate ptpcamerad process {pid}: {status}"), + }); + } + } + + Ok(true) +} + pub async fn open_prepared_mtp_session( prepared: PreparedMtpDevice, ) -> Result { @@ -132,21 +211,54 @@ pub async fn open_prepared_mtp_session( } pub async fn open_mtp_device(serial: Option<&str>) -> Result { - match serial { + let device = match serial { Some(serial) => { MtpDevice::builder() .known_devices(KNOWN_TP7) + .session_id(TP7_SESSION_ID) + .reuse_existing_session(true) .open_by_serial(serial) .await } None => { MtpDevice::builder() .known_devices(KNOWN_TP7) + .session_id(TP7_SESSION_ID) + .reuse_existing_session(true) .open_first() .await } } - .map_err(map_mtp_error) + .map_err(map_mtp_error)?; + + initialize_tp7_session(&device).await?; + + Ok(device) +} + +async fn initialize_tp7_session(device: &MtpDevice) -> Result<(), AppError> { + let session = device.session(); + + session + .get_device_prop_desc(DevicePropertyCode::BatteryLevel) + .await + .map_err(map_mtp_error)?; + device.storages().await.map_err(map_mtp_error)?; + device.storages().await.map_err(map_mtp_error)?; + session + .get_device_prop_value(DevicePropertyCode::BatteryLevel) + .await + .map_err(map_mtp_error)?; + session + .get_device_prop_value(DevicePropertyCode::BatteryLevel) + .await + .map_err(map_mtp_error)?; + session + .get_device_prop_value(DevicePropertyCode::DateTime) + .await + .map_err(map_mtp_error)?; + + Ok(()) } pub fn map_mtp_error(error: mtp_rs::Error) -> AppError { diff --git a/src/output.rs b/src/output.rs index c494f7b..4259095 100644 --- a/src/output.rs +++ b/src/output.rs @@ -108,6 +108,9 @@ pub enum AppError { #[error("MTP operation failed: {message}")] Mtp { message: String }, + #[error("could not take over the TP-7 MTP interface: {message}")] + MtpTakeover { message: String }, + #[error("MTP operation is not supported: {message}")] MtpUnsupported { message: String }, @@ -157,7 +160,7 @@ impl AppError { | AppError::RemotePathExists { .. } | AppError::InvalidArguments { .. } => 2, AppError::MtpNotVisible { .. } | AppError::AutoConnectRequired { .. } => 3, - AppError::MtpExclusiveAccess { .. } => 4, + AppError::MtpExclusiveAccess { .. } | AppError::MtpTakeover { .. } => 4, AppError::Midi { .. } | AppError::MidiTimeout { .. } | AppError::MidiCommandRejected { .. } => 5, diff --git a/src/pull.rs b/src/pull.rs index 7907b1a..8fd2c83 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -66,9 +66,9 @@ pub fn run_pull( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_pull(&session.device, remote_path, local_path, options).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), diff --git a/src/push.rs b/src/push.rs index 0e47f03..b3283dd 100644 --- a/src/push.rs +++ b/src/push.rs @@ -84,9 +84,9 @@ pub fn run_push( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_push(&session.device, &local_path, remote_path, options).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), diff --git a/src/stat.rs b/src/stat.rs index 7eb609e..f871982 100644 --- a/src/stat.rs +++ b/src/stat.rs @@ -27,9 +27,9 @@ pub fn run_stat( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_stat(&session.device, remote_path).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), diff --git a/src/status.rs b/src/status.rs index 12511ca..b7dad06 100644 --- a/src/status.rs +++ b/src/status.rs @@ -39,7 +39,7 @@ pub fn run_status(serial: Option<&str>) -> Result { let session = open_mtp_session(serial, MtpOpenPolicy::MtpOnly).await?; let usb = session.prepared.usb.clone(); let mtp = read_mtp_status(&session.device).await?; - session.close().await?; + session.release().await?; Ok(StatusReport { usb, mtp }) }) diff --git a/src/tree.rs b/src/tree.rs index c5d9655..a1018c7 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -36,9 +36,9 @@ pub fn run_tree( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = read_tree(&session.device, remote_path, depth).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), diff --git a/src/usb_owner.rs b/src/usb_owner.rs index ab28562..f287e82 100644 --- a/src/usb_owner.rs +++ b/src/usb_owner.rs @@ -89,6 +89,22 @@ pub fn is_conflicting_owner(owner: &UsbOwner) -> bool { || process.contains("ptpcamerad") } +pub fn ptpcamerad_exclusive_owner_pids(owners: &[UsbOwner]) -> Vec { + let mut pids = owners + .iter() + .filter(|owner| { + owner.kind == UsbOwnerKind::ExclusiveOwner + && owner.scope == UsbOwnerScope::Interface + && owner.process.eq_ignore_ascii_case("ptpcamerad") + }) + .filter_map(|owner| owner.pid) + .collect::>(); + + pids.sort_unstable(); + pids.dedup(); + pids +} + fn parse_ioreg_usb_owners(output: &str) -> Vec { let entries = parse_ioreg_entries(output); let mut owners = Vec::new(); @@ -267,7 +283,9 @@ fn owner_belongs_to_tp7_device(entries: &[IoregEntry], index: usize) -> bool { fn is_tp7_device_entry(entry: &IoregEntry) -> bool { entry.node_class == "IOUSBHostDevice" && entry.id_vendor == Some(crate::device::TP7_VENDOR_ID) - && entry.id_product == Some(crate::device::TP7_PRODUCT_ID) + && entry + .id_product + .is_some_and(crate::device::is_tp7_product_id) } fn nearest_scope_entry(entries: &[IoregEntry], index: usize) -> usize { @@ -432,4 +450,39 @@ mod tests { assert_eq!(owners[0].raw, "pid 92001, other process"); assert_eq!(owners[0].owner_node_name, "other process"); } + + #[test] + fn selects_only_exclusive_ptpcamerad_interface_owners() { + let output = r#" ++-o TP-7 MTP Device@01100000 + | { + | "idVendor" = 9063 + | "idProduct" = 25 + | } + | + +-o MTP Data Intf@0 + | | { + | | "bInterfaceProtocol" = 1 + | | "bInterfaceClass" = 6 + | | "bInterfaceSubClass" = 1 + | | "UsbExclusiveOwner" = "pid 28379, ptpcamerad" + | | "kUSBString" = "MTP Data Intf" + | | "bInterfaceNumber" = 0 + | | } + | | + | +-o ptpcamerad + | { + | "IOUserClientCreator" = "pid 28379, ptpcamerad" + | } + | + +-o field kit + { + "IOUserClientCreator" = "pid 19731, field kit" + } +"#; + + let owners = parse_ioreg_usb_owners(output); + + assert_eq!(ptpcamerad_exclusive_owner_pids(&owners), vec![28379]); + } } diff --git a/src/write_ops.rs b/src/write_ops.rs index 902107d..7425443 100644 --- a/src/write_ops.rs +++ b/src/write_ops.rs @@ -41,9 +41,9 @@ pub fn run_mkdir( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_mkdir(&session.device, remote_path, parents).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -63,9 +63,9 @@ pub fn run_rename( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_rename(&session.device, remote_path, new_name).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -86,9 +86,9 @@ pub fn run_rm( block_on(async { let session = open_mtp_session(serial, policy).await?; let result = write_rm(&session.device, remote_path, recursive, force, dry_run).await; - let close_result = session.close().await; + let release_result = session.release().await; - match (result, close_result) { + match (result, release_result) { (Ok(report), Ok(())) => Ok(report), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), diff --git a/vendor/mtp-rs/Cargo.toml b/vendor/mtp-rs/Cargo.toml new file mode 100644 index 0000000..a94c662 --- /dev/null +++ b/vendor/mtp-rs/Cargo.toml @@ -0,0 +1,154 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.85" +name = "mtp-rs" +version = "0.13.3" +build = false +exclude = [ + ".github/", + "AGENTS.md", + "docs/", + "justfile", + "deny.toml", + "clippy.toml", + "rustfmt.toml", + "proptest-regressions/", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Pure-Rust MTP (Media Transfer Protocol) library for modern Android devices" +documentation = "https://docs.rs/mtp-rs" +readme = "README.md" +keywords = [ + "mtp", + "android", + "usb", + "media", + "transfer", +] +categories = [ + "hardware-support", + "filesystem", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/vdavid/mtp-rs" + +[package.metadata.docs.rs] +all-features = true + +[features] +default = [] +virtual-device = ["notify"] + +[lib] +name = "mtp_rs" +path = "src/lib.rs" + +[[example]] +name = "debug_listing" +path = "examples/debug_listing.rs" + +[[example]] +name = "debug_pixel_slow" +path = "examples/debug_pixel_slow.rs" + +[[example]] +name = "diagnose" +path = "examples/diagnose.rs" + +[[example]] +name = "fuji_capture" +path = "examples/fuji_capture.rs" + +[[example]] +name = "fuji_rw_check" +path = "examples/fuji_rw_check.rs" + +[[example]] +name = "list_all_usb" +path = "examples/list_all_usb.rs" + +[[example]] +name = "list_and_download" +path = "examples/list_and_download.rs" + +[[example]] +name = "ptp_diagnose" +path = "examples/ptp_diagnose.rs" + +[[example]] +name = "test_capture" +path = "examples/test_capture.rs" + +[[example]] +name = "test_file_ops" +path = "examples/test_file_ops.rs" + +[[example]] +name = "test_partial_download_64" +path = "examples/test_partial_download_64.rs" + +[[test]] +name = "integration" +path = "tests/integration.rs" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.bytes] +version = "1.5" + +[dependencies.futures] +version = "0.3" + +[dependencies.futures-timer] +version = "3.0" + +[dependencies.notify] +version = "8" +optional = true + +[dependencies.num_enum] +version = "0.7" + +[dependencies.nusb] +version = "0.2" + +[dependencies.thiserror] +version = "2" + +[dev-dependencies.parking_lot] +version = "0.12" + +[dev-dependencies.proptest] +version = "1" + +[dev-dependencies.serial_test] +version = "3.1" + +[dev-dependencies.tempfile] +version = "3" + +[dev-dependencies.tokio] +version = "1.35" +features = [ + "rt-multi-thread", + "macros", + "time", + "fs", + "io-util", +] diff --git a/vendor/mtp-rs/LICENSE-APACHE b/vendor/mtp-rs/LICENSE-APACHE new file mode 100644 index 0000000..2251a4c --- /dev/null +++ b/vendor/mtp-rs/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2026 David Veszelovszki + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/mtp-rs/LICENSE-MIT b/vendor/mtp-rs/LICENSE-MIT new file mode 100644 index 0000000..cc09e8b --- /dev/null +++ b/vendor/mtp-rs/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 David Veszelovszki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/mtp-rs/README.md b/vendor/mtp-rs/README.md new file mode 100644 index 0000000..0a15352 --- /dev/null +++ b/vendor/mtp-rs/README.md @@ -0,0 +1,466 @@ +# mtp-rs + +[![Crates.io](https://img.shields.io/crates/v/mtp-rs)](https://crates.io/crates/mtp-rs) +[![docs.rs](https://img.shields.io/docsrs/mtp-rs)](https://docs.rs/mtp-rs) +[![CI](https://github.com/vdavid/mtp-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/vdavid/mtp-rs/actions/workflows/ci.yml) +[![License](https://img.shields.io/crates/l/mtp-rs)](LICENSE-MIT) +[![MSRV](https://img.shields.io/badge/MSRV-1.85-blue)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html) + +A pure-Rust, async MTP/PTP library. +No C dependencies, consistently faster than libmtp (up to 4x for large transfers), and way more predictable. + +Talk to Android phones, e-book readers incl. Kindle, and digital cameras over USB. +No `libmtp`, no `libusb`, no FFI, just async Rust built on [`nusb`](https://crates.io/crates/nusb). + +**Why this matters:** + +- Cross-compile without system lib headaches +- No `pkg-config`, no `-sys` crates, no `build.rs` surprises +- Works anywhere Rust compiles (including `musl` and cross-compilation targets) +- Fully async and runtime-agnostic +- [Virtual device mode](#virtual-device-testing-without-hardware) for testing without a USB device plugged in + +## What it does + +- Connect to devices over USB +- List, download, upload, delete, move, copy, and rename files +- Create, delete, and rename folders +- Stream large file downloads and uploads with continued progress indication +- Listen for device events (file added, storage removed, etc.) +- See free space +- Also exposes a lower-level interface for PTP, so it can be used for cameras too. + +## What it doesn't do + +- MTPZ (the DRM extension some old devices used) +- Playlists, tracks, albums, and custom operations +- Vendor-specific extensions +- Legacy Android device quirks (pre-5.0 devices) + +We intentionally didn't want to support these because they're rarely needed now, and it'd be a nightmare to test. +[libmtp](https://github.com/libmtp/libmtp/) has an impressive collection of device quirks, but it's LGPL-1.1 licensed, +and I wanted to do MIT/Apache-2.0 for broader access. So copying that code was also not an option. + +## Quick start + +A simple test would be this: + +```rust +use mtp_rs::mtp::MtpDevice; + +#[tokio::main] +async fn main() -> Result<(), mtp_rs::Error> { + // Connect to the first MTP device + let device = MtpDevice::open_first().await?; + + println!("Connected to {} {}", + device.device_info().manufacturer, + device.device_info().model); + + // List storages (internal storage, SD card, etc.) + for storage in device.storages().await? { + println!("{}: {:.2} GB free", + storage.info().description, + storage.info().free_space_bytes as f64 / 1e9); + + // List files in root + for file in storage.list_objects(None).await? { + let icon = if file.is_folder() { "📁" } else { "📄" }; + println!(" {} {}", icon, file.filename); + } + } + + Ok(()) +} +``` + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +mtp-rs = "0.12" +``` + +You'll also need an async runtime. The library is runtime-agnostic, but [tokio](https://github.com/tokio-rs/tokio) is +the most common choice: + +```toml +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +``` + +### Platform notes + +#### Linux + +You may need udev rules to access USB devices without root. Create `/etc/udev/rules.d/99-mtp.rules`: + +``` +SUBSYSTEM=="usb", ATTR{idVendor}=="*", MODE="0666" +``` + +Then run `sudo udevadm control --reload-rules`. + +#### macOS + +It's a bit of a nightmare because macOS's built-in `ptpcamerad` daemon automatically claims MTP/PTP devices right on +connection, blocking other apps. This sucks because it it NOT `MTP`, just `PTP`, so Android phones, Kindles, etc. +won't be able to sync files through it, and at the same time, other apps (like potentially yours if you're looking at +this) will be unable to access the device. 🤯 + +One more potential offender is [Android File Transfer](https://www.android-file-transfer-mac.com/): If installed, it +spawns a process that also grabs devices. You must quit it before trying to connect to an MTP device using this (or, +honestly, any) library. + +**Workarounds:** + +1. **Kill loop**: Run this in Terminal while using your app: + ```bash + while true; do pkill -9 ptpcamerad 2>/dev/null; sleep 1; done + ``` + +2. **Disable `ptpcamerad`**: Persistent, but may break Photos.app: + + ```bash + sudo launchctl disable system/com.apple.ptpcamerad + ``` + +**Other tips for app developers:** + +- This library provides `Error::is_exclusive_access()`. Use this to detect this condition and guide users to apply + one of the workarounds above. +- Query IORegistry for `UsbExclusiveOwner` to show which process (pid, name) holds the device for even more helpful info +- App Store sandboxed apps cannot kill processes. If your app is such, then provide the command for users to run + manually. + If your app isn't in the App Store, then you're in a better position and may be able to use the workarounds, BUT + it's a bit murky territory with Apple. +- See [Cmdr](https://github.com/vdavid/cmdr) and [Commander One](https://mac.eltima.com/file-manager.html) for UX + inspiration on handling this gracefully. + +#### Windows + +Should work, and no dependencies needed, but we haven't tested it. + +## Examples + +These might come in handy: + +### Download a file + +```rust +let storage = & device.storages().await?[0]; + +// Find a file +let files = storage.list_objects(None).await?; +let photo = files.iter().find( | f| f.filename == "photo.jpg").unwrap(); + +// Download it +let data = storage.download(photo.handle).await?.collect().await?; +std::fs::write("photo.jpg", data) ?; +``` + +### Upload a file + +```rust +use mtp_rs::mtp::NewObjectInfo; +use bytes::Bytes; + +let content = std::fs::read("document.pdf") ?; +let info = NewObjectInfo::file("document.pdf", content.len() as u64); + +let stream = futures::stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(content))]); +let handle = storage.upload(None, info, Box::pin(stream)).await?; + +println!("Uploaded with handle {:?}", handle); +``` + +### Download with progress + +```rust +let mut download = storage.download_stream(file.handle).await?; +println!("Downloading {} bytes...", download.size()); + +while let Some(chunk) = download.next_chunk().await { +let bytes = chunk ?; +// Process bytes... +println ! ("{:.1}%", download.progress() * 100.0); +} +``` + +### Partial reads (byte ranges) + +Useful for previews, thumbnails, streaming media, or random access into large files: + +```rust +// First 1 MB of a file +let head = storage.download_partial(file.handle, 0, 1024 * 1024).await?; + +// Read from the middle +let middle = storage.download_partial(file.handle, 5_000_000, 100_000).await?; +``` + +For files larger than 4 GB, use the 64-bit variant (requires device support — most modern +Android devices advertise it): + +```rust +// Read 64 KB at offset 6 GB +let chunk = storage.download_partial_64(file.handle, 6 * 1024 * 1024 * 1024, 65536).await?; +``` + +### Listen for events + +`next_event()` awaits indefinitely, so wrap it in a timeout to allow checking for shutdown, etc.: + +```rust +use tokio::time::{timeout, Duration}; + +loop { + match timeout(Duration::from_millis(200), device.next_event()).await { + Ok(Ok(event)) => match event { + DeviceEvent::ObjectAdded { handle } => { + println!("New file: {:?}", handle); + } + DeviceEvent::StoreRemoved { storage_id } => { + println!("Storage unplugged: {:?}", storage_id); + } + _ => {} + }, + Ok(Err(Error::Disconnected)) => break, + Ok(Err(e)) => eprintln!("Error: {}", e), + Err(_) => continue, // Timeout — check for shutdown, etc. + } +} +``` + +## Virtual device (testing without hardware) + +The `virtual-device` feature lets you test MTP client code against a local filesystem directory instead of a real USB +device. Enable it in your `Cargo.toml`: + +```toml +[dev-dependencies] +mtp-rs = { version = "0.4", features = ["virtual-device"] } +``` + +### Direct usage + +```rust +use std::path::PathBuf; +use std::time::Duration; +use mtp_rs::{MtpDevice, VirtualDeviceConfig, VirtualStorageConfig}; + +let device = MtpDevice::builder() + .open_virtual(VirtualDeviceConfig { + manufacturer: "Google".into(), + model: "Virtual Pixel 9".into(), + serial: "virtual-001".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 64 * 1024 * 1024 * 1024, + backing_dir: PathBuf::from("/tmp/mtp-test"), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::from_millis(50), + watch_backing_dirs: true, + }) + .await?; +``` + +When `watch_backing_dirs` is `true`, the virtual device watches its backing directories for external changes (files +created or removed outside of MTP) and emits `ObjectAdded`/`ObjectRemoved` events, just like a real device would. +Set it to `false` in tests that don't need this for faster startup. + +### Discovery registry + +You can also register virtual devices so they appear in `MtpDevice::list_devices()` and can be opened with +`open_by_location()` or `open_by_serial()`: + +```rust +use mtp_rs::{register_virtual_device, unregister_virtual_device}; + +let info = register_virtual_device(&config); + +// Now discoverable +let device = MtpDevice::builder().open_by_serial("virtual-001").await?; + +// Clean up when done +unregister_virtual_device(info.location_id); +``` + +## API overview + +The library has two layers: + +### High-level API (`mtp::`) + +This is what most people want. Friendly types, automatic session management, streaming. + +- `MtpDevice` - Connect to devices, get info, list storages +- `Storage` - File operations (list, download, upload, delete, move, copy) +- `DownloadStream` - Streaming downloads with progress +- `DeviceEvent` - Events from the device + +### Low-level API (`ptp::`) + +For when you need raw protocol access (for cameras or maybe debugging). + +- `PtpDevice` - Raw device connection +- `PtpSession` - Manual session control, raw operations +- `OperationCode`, `ResponseCode` - Protocol constants +- Container types for building/parsing protocol messages + +With this, you can copy stuff to/from cameras, but there are no other features like reading the battery level, +trigger capture, read supported formats/sizes, etc. This is intentional, didn't want to bloat the library with +camera-specific code because this is mainly for MTP and file transfer. + +## Runtime compatibility + +The library uses `futures` traits and is runtime-agnostic. It's tested with tokio but should work with async-std or any +other runtime. + +We use `nusb` for USB access, which is also runtime-agnostic. + +## Known limitations + +| Limitation | Details | +|---------------------------|----------------------------------------------------| +| Files >4GB (size field) | `ObjectInfo::size` is u32 and caps at 4 GB. For byte-range reads beyond 4 GB, use `download_partial_64()` (tested end-to-end on Pixel 9 Pro XL with an 8 GB file). | +| Filename length | Max 254 characters | +| Non-empty folder delete | Fails; delete contents first | +| One connection per device | Can't open the same device twice | +| Upload cancellation | Partial files may remain on device | +| Recursive listing speed | Manual traversal is slower (~1 request per folder) | + +## Android weirdnesses + +Android's MTP implementation has some quirks that this library handles automatically: + +- **Behavior:** Recursive listing broken + - **What happens:** `ObjectHandle::ALL` returns incomplete results (folders only, no files) + - **How we handle it:** Auto-detected; uses manual folder traversal instead. Although, note that it takes a lot more + time! Like, if the device supported this, it'd be pretty fast, while with the workaround, in the tests it took + 9 minutes to list ~20k files in ~2k folders. +- **Behavior:** Can't create in root + - **What happens:** Creating files/folders in storage root fails with `InvalidObjectHandle` + - **How we handle it:** Use a subfolder like `Download/` as the parent +- **Behavior:** Large responses span transfers + - **What happens:** Data >64KB comes in multiple USB transfers + - **How we handle it:** Automatically reassembled before parsing +- **Behavior:** Composite USB devices + - **What happens:** Most phones report as USB class 0 (composite) + - **How we handle it:** We inspect interfaces to find MTP + +The library detects Android devices via the `"android.com"` vendor extension and applies appropriate handling +automatically. +You generally don't need to worry about these details. + +**Tip**: When uploading files, use a known folder like `Download/` rather than the storage root: + +```rust +// Find the Download folder +let objects = storage.list_objects(None).await?; +let download = objects.iter().find( | o| o.filename == "Download").unwrap(); + +// Upload to Download folder (not root) +storage.upload(Some(download.handle), file_info, data).await?; +``` + +## Tested devices + +"Full support" really means "Full support, except for general Android quirks listed above". + +| Device | Android | Notes | +|--------------------------------------------------|---------|-----------------| +| Google Pixel 9 Pro XL | 15 | Full support | +| Samsung Galaxy S23 Ultra (SM-S918B) | 14 | No root listing | +| [Amazon Kindle Paperwhite 12th Generation (2024)](https://github.com/vdavid/mtp-rs/pull/2#issuecomment-4264713119) | - | Full support | +| [Fairphone 5](https://github.com/vdavid/mtp-rs/issues/6#issuecomment-4234861708) (e/OS 3.0.4, LineageOS-derived) | 13 | Full support | +| [Garmin Forerunner 955](https://github.com/vdavid/mtp-rs/pull/10) | - | Works for app use; integration suite has one failing test under investigation | + +**Samsung quirk**: Samsung devices return `InvalidObjectHandle` when listing the root folder with handle 0. +The library automatically detects this and falls back to recursive listing with filtering. This is transparent to users. + +We welcome reports of other tested devices! Please open an issue or PR with your device model, Android version, +and any issues encountered. + +## Benchmarks + +mtp-rs is faster than libmtp across every operation we tested, and the gap widens with file size. On a Google Pixel 9 +Pro XL (USB, 5 warmup + 10 measured runs per scenario): + +| Operation | Size | mtp-rs | libmtp | Speedup | +|------------|--------|---------|---------|-----------| +| download | 1 MB | 33.9ms | 45.3ms | **1.34x** | +| download | 10 MB | 258.3ms | 391.1ms | **1.51x** | +| download | 100 MB | 2.447s | 9.897s | **4.04x** | +| upload | 1 MB | 76.1ms | 115.0ms | **1.51x** | +| upload | 10 MB | 326.9ms | 345.1ms | **1.06x** | +| upload | 100 MB | 2.388s | 2.796s | **1.17x** | +| list_files | - | 15.5ms | 24.9ms | **1.61x** | + +Beyond raw speed, mtp-rs is far more predictable. At 100 MB downloads, libmtp's individual runs ranged from 3.7s to +18.2s (std dev 4.6s — that's 47% of its median). mtp-rs stayed within a 15ms band (std dev 4.7ms — 0.2% of its median). +In practice this means a 100 MB transfer with mtp-rs reliably takes ~2.4s, while with libmtp it could take anywhere from +4s to 18s. + +For large-file throughput, a separate test uploaded an 8.06 GB file to a Pixel 9 Pro XL over USB 3.2 in 98.5s — +sustained **83.8 MB/s**. Partial reads at offsets 2 GB, 4.1 GB, and near-EOF all returned correct bytes, confirming +`GetPartialObject64` works end-to-end on Android. + +The benchmark tool is included in the repo. [Run it yourself](benchmarks/mtp-rs-vs-libmtp/) with +`cargo run -p mtp-bench -- --warmup 5 --runs 10`. + +## Comparison with other libraries + +### vs libmtp / libmtp-rs + +[libmtp](https://github.com/libmtp/libmtp/) is 20+ years old, battle-tested, and very comprehensive. +[libmtp-rs](https://github.com/quebin31/libmtp-rs) provides a Rust interface to it. But: + +- `libmtp` is a C library with all the FFI pain that entails +- It has a massive device quirks database for hardware from 2006 +- The API is synchronous and callback-heavy +- It pulls in `libusb`, `libudev`, and other system dependencies + +In contrast, `mtp-rs` targets modern Android devices that all behave the same way. If you need to support a weird +MP3 player from 2008, use libmtp. If you're building a modern Android sync tool, mtp-rs is a better fit. + +### vs existing Rust PTP crates + +[ptp](https://crates.io/crates/ptp) and [libptp](https://crates.io/crates/libptp) both use +[libusb](https://github.com/libusb/libusb) v0.3 for USB access, which is a C dependency. + +`mtp-rs` uses [nusb](https://crates.io/crates/nusb) instead, which is pure Rust. + +Note that `libptp` is much more mature, though! + +### vs winmtp + +[winmtp](https://crates.io/crates/winmtp) wraps the Windows COM API, which is Windows only. `mtp-rs` works on Linux, +macOS, and Windows. + +## Implementation notes + +- I used Opus 4.5 extensively for this implementation. I know it's controversial these days, but the bottom line to me + is that the implementation WORKS, it has a bunch of integration tests which pass, and hey, I can use it to copy data + to/from my phone and other phones and I can display async progress and I don't need to rely on C libraries. So no + hate, + please. If you dislike or distrust AI-gen code, use the alternatives listed above (if you can live with the libmtp + dependency), handcraft your own Rust implementation, or fork this repo and add your human thing and use it. + PRs are also welcome. +- For the protocol spec, I tried to use + usb.org's [Media Transfer Protocol v.1.1 Spec](https://www.usb.org/document-library/media-transfer-protocol-v11-spec-and-mtp-v11-adopters-agreement), + but it was a pain to get AI agents to work from it, so I've converted it to Markdown. You can find it + here: https://github.com/vdavid/mtp-v1_1-spec-md + I've also shared it back with the USB.org team, so they might link it on the official page. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +MIT OR Apache-2.0, at your option. diff --git a/vendor/mtp-rs/TP7-PATCH.md b/vendor/mtp-rs/TP7-PATCH.md new file mode 100644 index 0000000..d309222 --- /dev/null +++ b/vendor/mtp-rs/TP7-PATCH.md @@ -0,0 +1,12 @@ +# TP-7 session-reuse patch + +This is `mtp-rs` 0.13.3 with a small opt-in session policy used by the TP-7 +CLI. The patch adds: + +- `PtpSession::open_reusing_existing` +- `MtpDeviceBuilder::session_id` +- `MtpDeviceBuilder::reuse_existing_session` + +Default `mtp-rs` behavior is unchanged. The TP-7 opts into reuse because its +firmware treats `CloseSession` as a request to leave MTP mode. FieldKit uses a +stable session ID and reuses the device-side session across app processes. diff --git a/vendor/mtp-rs/src/error.rs b/vendor/mtp-rs/src/error.rs new file mode 100644 index 0000000..bcd7a9a --- /dev/null +++ b/vendor/mtp-rs/src/error.rs @@ -0,0 +1,176 @@ +//! Error types for mtp-rs. + +use thiserror::Error; + +/// The main error type for mtp-rs operations. +#[derive(Debug, Error)] +pub enum Error { + /// USB communication error + #[error("USB error: {0}")] + Usb(#[from] nusb::Error), + + /// Protocol-level error from device + #[error("Protocol error: {code:?} during {operation:?}")] + Protocol { + /// The response code returned by the device. + code: crate::ptp::ResponseCode, + /// The operation that triggered the error. + operation: crate::ptp::OperationCode, + }, + + /// Invalid data received from device + #[error("Invalid data: {message}")] + InvalidData { + /// Description of what was invalid. + message: String, + }, + + /// I/O error + #[error("I/O error: {0}")] + Io(std::io::Error), + + /// Operation timed out + #[error("Operation timed out")] + Timeout, + + /// Device was disconnected + #[error("Device disconnected")] + Disconnected, + + /// Session not open + #[error("Session not open")] + SessionNotOpen, + + /// No device found + #[error("No MTP device found")] + NoDevice, + + /// Operation cancelled + #[error("Operation cancelled")] + Cancelled, +} + +impl Error { + /// Create an invalid data error with a message. + #[must_use] + pub fn invalid_data(message: impl Into) -> Self { + Error::InvalidData { + message: message.into(), + } + } + + /// Check if this is a retryable error. + /// + /// Retryable errors are transient and the operation may succeed if retried: + /// - `DeviceBusy`: Device is temporarily busy + /// - `Timeout`: Operation timed out but device may still be responsive + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Error::Protocol { + code: crate::ptp::ResponseCode::DeviceBusy, + .. + } | Error::Timeout + ) + } + + /// Get the response code if this is a protocol error. + #[must_use] + pub fn response_code(&self) -> Option { + match self { + Error::Protocol { code, .. } => Some(*code), + _ => None, + } + } + + /// Check if this error indicates another process has exclusive access to the device. + /// + /// This typically happens on macOS when `ptpcamerad` or another application + /// has already claimed the USB interface. Applications can use this to provide + /// platform-specific guidance to users. + /// + /// # Example + /// + /// ```ignore + /// match device.open().await { + /// Err(e) if e.is_exclusive_access() => { + /// // On macOS, likely ptpcamerad interference + /// // App can query IORegistry for UsbExclusiveOwner to get details + /// show_exclusive_access_help(); + /// } + /// Err(e) => handle_other_error(e), + /// Ok(dev) => use_device(dev), + /// } + /// ``` + #[must_use] + pub fn is_exclusive_access(&self) -> bool { + match self { + Error::Usb(io_err) => { + let msg = io_err.to_string().to_lowercase(); + // macOS: "could not be opened for exclusive access" + // Linux: typically EBUSY, but message varies + // Windows: "access denied" or similar + msg.contains("exclusive access") + || msg.contains("device or resource busy") + || (msg.contains("access") && msg.contains("denied")) + } + Error::Io(io_err) => { + let msg = io_err.to_string().to_lowercase(); + msg.contains("exclusive access") + || msg.contains("device or resource busy") + || (msg.contains("access") && msg.contains("denied")) + } + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Error as IoError, ErrorKind}; + + #[test] + fn test_is_exclusive_access_macos_message() { + // macOS nusb error message (tested via Io variant; same logic as Usb variant) + let io_err = IoError::other("could not be opened for exclusive access"); + let err = Error::Io(io_err); + assert!(err.is_exclusive_access()); + } + + #[test] + fn test_is_exclusive_access_linux_busy() { + // Linux EBUSY style message (tested via Io variant; same logic as Usb variant) + let io_err = IoError::other("Device or resource busy"); + let err = Error::Io(io_err); + assert!(err.is_exclusive_access()); + } + + #[test] + fn test_is_exclusive_access_windows_denied() { + // Windows access denied style message (tested via Io variant; same logic as Usb variant) + let io_err = IoError::new(ErrorKind::PermissionDenied, "Access is denied"); + let err = Error::Io(io_err); + assert!(err.is_exclusive_access()); + } + + #[test] + fn test_is_exclusive_access_io_error() { + // Also works for Io variant + let io_err = IoError::other("could not be opened for exclusive access"); + let err = Error::Io(io_err); + assert!(err.is_exclusive_access()); + } + + #[test] + fn test_is_exclusive_access_false_for_other_errors() { + assert!(!Error::Timeout.is_exclusive_access()); + assert!(!Error::Disconnected.is_exclusive_access()); + assert!(!Error::NoDevice.is_exclusive_access()); + assert!(!Error::invalid_data("some error").is_exclusive_access()); + + let io_err = IoError::new(ErrorKind::NotFound, "device not found"); + assert!(!Error::Io(io_err).is_exclusive_access()); + } +} diff --git a/vendor/mtp-rs/src/lib.rs b/vendor/mtp-rs/src/lib.rs new file mode 100644 index 0000000..1258cc3 --- /dev/null +++ b/vendor/mtp-rs/src/lib.rs @@ -0,0 +1,69 @@ +//! # mtp-rs +//! +//! A pure-Rust MTP (Media Transfer Protocol) library targeting modern Android devices. +//! +//! ## Features +//! +//! - **Runtime agnostic**: Uses `futures` traits, works with any async runtime +//! - **Two-level API**: High-level `mtp::` for media devices, low-level `ptp::` for cameras +//! - **Streaming**: Memory-efficient streaming downloads with progress tracking +//! - **Type safe**: Newtype wrappers prevent mixing up IDs +//! +//! ## Quick start +//! +//! ```rust,no_run +//! use mtp_rs::mtp::MtpDevice; +//! +//! # async fn example() -> Result<(), mtp_rs::Error> { +//! // Open the first MTP device +//! let device = MtpDevice::open_first().await?; +//! +//! println!("Connected to: {} {}", +//! device.device_info().manufacturer, +//! device.device_info().model); +//! +//! // Get storages +//! for storage in device.storages().await? { +//! println!("Storage: {} ({} free)", +//! storage.info().description, +//! storage.info().free_space_bytes); +//! +//! // List root folder +//! for obj in storage.list_objects(None).await? { +//! let kind = if obj.is_folder() { "DIR " } else { "FILE" }; +//! println!(" {} {} ({} bytes)", kind, obj.filename, obj.size); +//! } +//! } +//! # Ok(()) +//! # } +//! ``` + +pub mod error; +pub mod mtp; +pub mod ptp; +pub mod transport; + +pub use error::Error; + +// Re-export core types for convenience +pub use ptp::{ + receive_stream_to_stream, DateTime, EventCode, ObjectFormatCode, ObjectHandle, OperationCode, + ReceiveStream, ResponseCode, SessionId, StorageId, TransactionId, +}; + +// Re-export high-level MTP types +pub use mtp::{ + DeviceEvent, FileDownload, MtpDevice, MtpDeviceBuilder, NewObjectInfo, ObjectListing, Progress, + Storage, DEFAULT_CANCEL_TIMEOUT, +}; + +// Re-export virtual device types when the feature is enabled +#[cfg(feature = "virtual-device")] +pub use transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; +#[cfg(feature = "virtual-device")] +pub use transport::virtual_device::registry::{ + pause_watcher, register_virtual_device, rescan_virtual_device, unregister_virtual_device, + WatcherGuard, +}; +#[cfg(feature = "virtual-device")] +pub use transport::virtual_device::RescanSummary; diff --git a/vendor/mtp-rs/src/mtp/device.rs b/vendor/mtp-rs/src/mtp/device.rs new file mode 100644 index 0000000..b90bcec --- /dev/null +++ b/vendor/mtp-rs/src/mtp/device.rs @@ -0,0 +1,728 @@ +//! MtpDevice - the main entry point for MTP operations. + +use crate::mtp::{DeviceEvent, Storage}; +use crate::ptp::{DeviceInfo, ObjectHandle, PtpSession, StorageId}; +use crate::transport::{NusbTransport, Transport}; +use crate::Error; +use std::sync::Arc; +use std::time::Duration; + +/// Internal shared state for MtpDevice. +pub(crate) struct MtpDeviceInner { + pub(crate) session: Arc, + pub(crate) device_info: DeviceInfo, +} + +impl MtpDeviceInner { + /// Check if the device is an Android device. + /// + /// Detected by looking for "android.com" in the vendor extension descriptor. + /// Android devices have known MTP quirks (e.g., ObjectHandle::ALL doesn't work + /// for recursive listing). + #[must_use] + pub fn is_android(&self) -> bool { + self.device_info + .vendor_extension_desc + .to_lowercase() + .contains("android.com") + } +} + +/// An MTP device connection. +/// +/// This is the main entry point for interacting with MTP devices. +/// Use `MtpDevice::open_first()` to connect to the first available device, +/// or `MtpDevice::builder()` for more control. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::mtp::MtpDevice; +/// +/// # async fn example() -> Result<(), mtp_rs::Error> { +/// // Open the first MTP device +/// let device = MtpDevice::open_first().await?; +/// +/// println!("Connected to: {} {}", +/// device.device_info().manufacturer, +/// device.device_info().model); +/// +/// // Get storages +/// for storage in device.storages().await? { +/// println!("Storage: {} ({} free)", +/// storage.info().description, +/// storage.info().free_space_bytes); +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct MtpDevice { + inner: Arc, +} + +impl MtpDevice { + /// Create a builder for configuring device options. + pub fn builder() -> MtpDeviceBuilder { + MtpDeviceBuilder::new() + } + + /// Open the first available MTP device with default settings. + pub async fn open_first() -> Result { + Self::builder().open_first().await + } + + /// Open a device at a specific USB location (port) with default settings. + /// + /// Use `list_devices()` to get available location IDs. + pub async fn open_by_location(location_id: u64) -> Result { + Self::builder().open_by_location(location_id).await + } + + /// Open a device by its serial number with default settings. + /// + /// This identifies a specific physical device regardless of which USB port + /// it's connected to. + pub async fn open_by_serial(serial: &str) -> Result { + Self::builder().open_by_serial(serial).await + } + + /// List all available MTP devices without opening them. + pub fn list_devices() -> Result, Error> { + Self::list_devices_with_known(&[]) + } + + /// List all available MTP devices, including additional devices identified + /// by the given VID/PID pairs. + /// + /// Devices matching the provided VID/PID pairs are included in the results + /// even if their USB descriptors don't match standard MTP class codes. This + /// is useful for legacy or otherwise unusual devices with non-standard USB + /// descriptors that still speak MTP. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// + /// let devices = MtpDevice::list_devices_with_known(&[ + /// (0x045E, 0x0710), // custom VID/PID + /// ])?; + /// for d in &devices { + /// println!("{:04x}:{:04x} {}", d.vendor_id, d.product_id, + /// d.product.as_deref().unwrap_or("unknown")); + /// } + /// # Ok::<(), mtp_rs::Error>(()) + /// ``` + pub fn list_devices_with_known(known: &[(u16, u16)]) -> Result, Error> { + let devices = NusbTransport::list_mtp_devices_with_known(known)?; + #[allow(unused_mut)] + let mut result: Vec = devices + .into_iter() + .map(|d| MtpDeviceInfo { + vendor_id: d.vendor_id, + product_id: d.product_id, + manufacturer: d.manufacturer, + product: d.product, + serial_number: d.serial_number, + location_id: d.location_id, + }) + .collect(); + + #[cfg(feature = "virtual-device")] + result.extend(crate::transport::virtual_device::registry::list_virtual_devices()); + + Ok(result) + } + + /// Get device information. + #[must_use] + pub fn device_info(&self) -> &DeviceInfo { + &self.inner.device_info + } + + /// Get a reference to the underlying [`PtpSession`]. + /// + /// Provides direct access to low-level PTP operations + /// ([`execute`](PtpSession::execute), + /// [`execute_with_send`](PtpSession::execute_with_send), + /// [`execute_with_receive`](PtpSession::execute_with_receive)) + /// and per-device knobs like + /// [`set_split_header_data`](PtpSession::set_split_header_data). + #[must_use] + pub fn session(&self) -> &PtpSession { + &self.inner.session + } + + /// Check if the device supports renaming objects. + /// + /// This checks for support of the SetObjectPropValue operation (0x9804), + /// which is required to rename files and folders via the ObjectFileName property. + /// + /// # Returns + /// + /// Returns true if the device advertises SetObjectPropValue support. + #[must_use] + pub fn supports_rename(&self) -> bool { + self.inner.device_info.supports_rename() + } + + /// Get all storages on the device. + pub async fn storages(&self) -> Result, Error> { + let ids = self.inner.session.get_storage_ids().await?; + let mut storages = Vec::with_capacity(ids.len()); + for id in ids { + let info = self.inner.session.get_storage_info(id).await?; + storages.push(Storage::new(self.inner.clone(), id, info)); + } + Ok(storages) + } + + /// Get a specific storage by ID. + pub async fn storage(&self, id: StorageId) -> Result { + let info = self.inner.session.get_storage_info(id).await?; + Ok(Storage::new(self.inner.clone(), id, info)) + } + + /// Get object handles in a storage. + /// + /// # Arguments + /// + /// * `storage_id` - Storage to search, or `StorageId::ALL` for all storages + /// * `parent` - Parent folder handle, or `None` for root level only, + /// or `Some(ObjectHandle::ALL)` for recursive listing + pub async fn get_object_handles( + &self, + storage_id: StorageId, + parent: Option, + ) -> Result, Error> { + self.inner + .session + .get_object_handles(storage_id, None, parent) + .await + } + + /// Receive the next event from the device. + /// + /// This method awaits **indefinitely** on the USB interrupt endpoint until an + /// event arrives or the device disconnects. Always wrap this in + /// `tokio::time::timeout` (or equivalent) so you can check for shutdown. + /// + /// # Concurrency + /// + /// Event reading uses the USB interrupt endpoint, which is independent from the + /// bulk endpoints used by file operations (`list_objects`, `get_object`, etc.). + /// It is safe to call `next_event()` concurrently with other `MtpDevice` methods. + /// + /// If you wrap `MtpDevice` in a shared lock (for example, `Arc>`), + /// do **not** hold that lock while awaiting `next_event()` — it will block all file + /// operations for the duration of the wait. Instead, clone the `MtpDevice` (it is + /// cheaply cloneable via `Arc` internally) and call `next_event()` on the clone + /// without holding the lock. + /// + /// # Returns + /// + /// - `Ok(event)` - An event was received from the device + /// - `Err(Error::Disconnected)` - Device was disconnected + /// - `Err(_)` - Other communication error + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::{MtpDevice, DeviceEvent}; + /// use mtp_rs::Error; + /// use tokio::time::{timeout, Duration}; + /// + /// # async fn example() -> Result<(), Error> { + /// # let device = MtpDevice::open_first().await?; + /// loop { + /// match timeout(Duration::from_millis(200), device.next_event()).await { + /// Ok(Ok(event)) => { + /// match event { + /// DeviceEvent::ObjectAdded { handle } => { + /// println!("New object: {:?}", handle); + /// } + /// DeviceEvent::StoreRemoved { storage_id } => { + /// println!("Storage removed: {:?}", storage_id); + /// } + /// _ => {} + /// } + /// } + /// Ok(Err(Error::Disconnected)) => break, + /// Ok(Err(e)) => { + /// eprintln!("Error: {}", e); + /// break; + /// } + /// Err(_elapsed) => continue, // Timeout, check for shutdown etc. + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn next_event(&self) -> Result { + match self.inner.session.poll_event().await? { + Some(container) => Ok(DeviceEvent::from_container(&container)), + None => Err(Error::Timeout), + } + } + + /// Close the connection (also happens on drop). + pub async fn close(self) -> Result<(), Error> { + // Try to close gracefully, but Arc might have multiple references + if let Ok(inner) = Arc::try_unwrap(self.inner) { + if let Ok(session) = Arc::try_unwrap(inner.session) { + session.close().await?; + } + } + Ok(()) + } +} + +/// Information about an MTP device (without opening it). +/// +/// This struct provides device identification at multiple levels: +/// +/// - **Device identity** (`vendor_id`, `product_id`, `serial_number`): Identifies +/// a specific physical device. Use this to recognize "John's phone" regardless +/// of which USB port it's plugged into. +/// +/// - **Port identity** (`location_id`): Identifies the physical USB port/location. +/// Use this when you care about "the device on port 3" rather than which +/// specific device it is. Stable across reconnections to the same port. +/// +/// - **Display info** (`manufacturer`, `product`): Human-readable strings for +/// showing device info to users. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::mtp::MtpDevice; +/// +/// let devices = MtpDevice::list_devices()?; +/// for dev in &devices { +/// println!("{} {} (serial: {:?})", +/// dev.manufacturer.as_deref().unwrap_or("Unknown"), +/// dev.product.as_deref().unwrap_or("Unknown"), +/// dev.serial_number); +/// } +/// +/// // Save location_id to remember "the device on this port" +/// // Save serial_number to remember "this specific phone" +/// # Ok::<(), mtp_rs::Error>(()) +/// ``` +#[derive(Debug, Clone)] +pub struct MtpDeviceInfo { + /// USB vendor ID (assigned by USB-IF to each company). + /// + /// Examples: Google = `0x18d1`, Samsung = `0x04e8`, Apple = `0x05ac` + pub vendor_id: u16, + + /// USB product ID (assigned by vendor to each product model). + /// + /// Note: The same device may report different product IDs depending on + /// its USB mode (MTP, ADB, charging-only, etc.). + pub product_id: u16, + + /// Manufacturer name from USB descriptor. + /// + /// Examples: `"Google"`, `"Samsung"`, `"Apple Inc."` + /// + /// `None` if the device doesn't report a manufacturer string. + pub manufacturer: Option, + + /// Product name from USB descriptor. + /// + /// Examples: `"Pixel 9 Pro XL"`, `"Galaxy S24"` + /// + /// `None` if the device doesn't report a product string. + pub product: Option, + + /// Serial number uniquely identifying this specific device. + /// + /// Combined with `vendor_id` and `product_id`, this globally identifies + /// a single physical device. Survives reconnection to different ports. + /// + /// `None` if the device doesn't report a serial number. + pub serial_number: Option, + + /// Physical USB location identifier. + /// + /// Identifies the USB port/path where the device is connected. Stable + /// across reconnections to the same physical port, but changes if the + /// device is moved to a different port. + /// + /// Derived cross-platform from the USB bus ID and port chain (topology). + pub location_id: u64, +} + +impl MtpDeviceInfo { + /// Format the device info for display. + #[must_use] + pub fn display(&self) -> String { + let manufacturer = self.manufacturer.as_deref().unwrap_or("Unknown"); + let product = self.product.as_deref().unwrap_or("Unknown"); + match &self.serial_number { + Some(serial) => format!( + "{} {} (serial: {}, location: {:08x})", + manufacturer, product, serial, self.location_id + ), + None => format!( + "{} {} (location: {:08x})", + manufacturer, product, self.location_id + ), + } + } +} + +/// Builder for MtpDevice configuration. +pub struct MtpDeviceBuilder { + timeout: Duration, + known_devices: Vec<(u16, u16)>, + session_id: u32, + reuse_existing_session: bool, +} + +impl MtpDeviceBuilder { + #[must_use] + pub fn new() -> Self { + Self { + timeout: NusbTransport::DEFAULT_TIMEOUT, + known_devices: Vec::new(), + session_id: 1, + reuse_existing_session: false, + } + } + + /// Set bulk transfer timeout (default: 30 seconds). + /// + /// This timeout applies to file transfers, command responses, and event polling. + /// Use longer timeouts for large file operations. + #[must_use] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Include additional devices identified by VID/PID pairs in the open-time + /// device scan. + /// + /// By default, the `open_first` / `open_by_serial` / `open_by_location` + /// convenience methods only consider devices whose USB descriptors match + /// standard MTP class codes. Pass extra VID/PID pairs here to also accept + /// legacy or otherwise unusual devices that speak MTP despite reporting + /// non-standard descriptors. + /// + /// This is the open-side counterpart to [`MtpDevice::list_devices_with_known`]: + /// pair them with the same list to enumerate and open the same set of devices. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// let known = &[(0x045E, 0x0710)]; + /// + /// // Enumerate with the extra VID/PID... + /// let devices = MtpDevice::list_devices_with_known(known)?; + /// let info = devices.into_iter().next().ok_or(mtp_rs::Error::NoDevice)?; + /// + /// // ...and open with the same list so the builder can find the device. + /// let device = MtpDevice::builder() + /// .known_devices(known) + /// .open_by_location(info.location_id) + /// .await?; + /// + /// // Apply any per-device knobs after opening. + /// device.session().set_split_header_data(true); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn known_devices(mut self, known: &[(u16, u16)]) -> Self { + self.known_devices = known.to_vec(); + self + } + + /// Set the PTP session identifier used by this connection. + #[must_use] + pub fn session_id(mut self, session_id: u32) -> Self { + self.session_id = session_id; + self + } + + /// Reuse a session that the device reports as already open. + #[must_use] + pub fn reuse_existing_session(mut self, reuse: bool) -> Self { + self.reuse_existing_session = reuse; + self + } + + /// Open the first available device. + pub async fn open_first(self) -> Result { + let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; + let device_info = devices.into_iter().next().ok_or(Error::NoDevice)?; + let device = device_info.open().map_err(Error::Usb)?; + self.open_nusb_device(device).await + } + + /// Open a device at a specific USB location (port). + /// + /// Use `MtpDevice::list_devices()` to get available location IDs. + /// Also checks the virtual device registry when the `virtual-device` feature is enabled. + pub async fn open_by_location(self, location_id: u64) -> Result { + #[cfg(feature = "virtual-device")] + if let Some(config) = + crate::transport::virtual_device::registry::find_virtual_config_by_location(location_id) + { + return self.open_virtual(config).await; + } + + let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; + let device_info = devices + .into_iter() + .find(|d| d.location_id == location_id) + .ok_or(Error::NoDevice)?; + let device = device_info.open().map_err(Error::Usb)?; + self.open_nusb_device(device).await + } + + /// Open a device by its serial number. + /// + /// This identifies a specific physical device regardless of which USB port + /// it's connected to. Also checks the virtual device registry when the + /// `virtual-device` feature is enabled. + pub async fn open_by_serial(self, serial: &str) -> Result { + #[cfg(feature = "virtual-device")] + if let Some(config) = + crate::transport::virtual_device::registry::find_virtual_config_by_serial(serial) + { + return self.open_virtual(config).await; + } + + let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; + let device_info = devices + .into_iter() + .find(|d| d.serial_number.as_deref() == Some(serial)) + .ok_or(Error::NoDevice)?; + let device = device_info.open().map_err(Error::Usb)?; + self.open_nusb_device(device).await + } + + /// Open an already-acquired [`nusb::Device`] as an MTP device. + /// + /// This is an escape hatch for consumers who already hold an `nusb::Device` + /// (e.g. from a custom enumeration or hotplug watcher). For most callers, + /// prefer [`known_devices`](Self::known_devices) combined with + /// `open_by_serial` / `open_by_location`. + /// + /// The interface scan is permissive: strict MTP-class match first, then + /// fallback to any interface with the MTP endpoint layout. Per-device knobs + /// like [`PtpSession::set_split_header_data`] can be applied after opening + /// via [`MtpDevice::session()`]. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// use nusb::MaybeFuture; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// let nusb_device = nusb::list_devices() + /// .wait()? + /// .find(|d: &nusb::DeviceInfo| { + /// d.vendor_id() == 0x045E && d.product_id() == 0x0710 + /// }) + /// .ok_or(mtp_rs::Error::NoDevice)? + /// .open() + /// .wait()?; + /// + /// let device = MtpDevice::builder() + /// .open_nusb_device(nusb_device) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn open_nusb_device(self, device: nusb::Device) -> Result { + let transport = NusbTransport::open_with_timeout(device, self.timeout).await?; + let transport: Arc = Arc::new(transport); + + let session = if self.reuse_existing_session { + PtpSession::open_reusing_existing(transport.clone(), self.session_id).await? + } else { + PtpSession::open(transport.clone(), self.session_id).await? + }; + let session = Arc::new(session); + + // Get device info + let device_info = session.get_device_info().await?; + + // Quirk for Garmin devices + if device_info.manufacturer == "Garmin" { + session.set_split_header_data(true); + } + + let inner = Arc::new(MtpDeviceInner { + session, + device_info, + }); + + Ok(MtpDevice { inner }) + } + + /// Open a virtual device backed by local filesystem directories. + /// + /// This creates a virtual MTP device that speaks the full binary protocol but + /// operates against local directories instead of USB. Use this for testing MTP + /// client code without real hardware. + /// + /// # Example + /// + /// ```rust,no_run + /// use std::path::PathBuf; + /// use std::time::Duration; + /// use mtp_rs::MtpDevice; + /// use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// let device = MtpDevice::builder() + /// .open_virtual(VirtualDeviceConfig { + /// manufacturer: "Google".into(), + /// model: "Virtual Pixel 9".into(), + /// serial: "virtual-001".into(), + /// storages: vec![VirtualStorageConfig { + /// description: "Internal Storage".into(), + /// capacity: 64 * 1024 * 1024 * 1024, + /// backing_dir: PathBuf::from("/tmp/mtp-test"), + /// read_only: false, + /// }], + /// supports_rename: true, + /// event_poll_interval: Duration::from_millis(50), + /// watch_backing_dirs: true, + /// }) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "virtual-device")] + pub async fn open_virtual( + self, + config: crate::transport::virtual_device::config::VirtualDeviceConfig, + ) -> Result { + if config.storages.is_empty() { + return Err(Error::invalid_data( + "VirtualDeviceConfig requires at least one storage", + )); + } + + let transport = crate::transport::virtual_device::VirtualTransport::new(config); + let transport: Arc = Arc::new(transport); + + // Open session (use session ID 1) + let session = Arc::new(PtpSession::open(transport.clone(), 1).await?); + + // Get device info + let device_info = session.get_device_info().await?; + + let inner = Arc::new(MtpDeviceInner { + session, + device_info, + }); + + Ok(MtpDevice { inner }) + } +} + +impl Default for MtpDeviceBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_devices_returns_ok() { + assert!(MtpDevice::list_devices().is_ok()); + } + + #[test] + fn builder_timeout() { + // Default value + let builder = MtpDeviceBuilder::new(); + assert_eq!(builder.timeout, NusbTransport::DEFAULT_TIMEOUT); + + // Custom value + let custom = MtpDeviceBuilder::new().timeout(Duration::from_secs(45)); + assert_eq!(custom.timeout, Duration::from_secs(45)); + } + + #[test] + fn device_info_display() { + let with_serial = MtpDeviceInfo { + vendor_id: 0x04e8, + product_id: 0x6860, + manufacturer: Some("Samsung".to_string()), + product: Some("Galaxy S24".to_string()), + serial_number: Some("ABC123".to_string()), + location_id: 0x00200000, + }; + let display = with_serial.display(); + assert!(display.contains("Samsung") && display.contains("Galaxy S24")); + assert!(display.contains("ABC123") && display.contains("00200000")); + + // Without serial + let no_serial = MtpDeviceInfo { + serial_number: None, + ..with_serial.clone() + }; + assert!(!no_serial.display().contains("serial:")); + + // Unknown manufacturer + let unknown = MtpDeviceInfo { + manufacturer: None, + product: None, + ..with_serial + }; + assert!(unknown.display().contains("Unknown")); + } + + #[cfg(feature = "virtual-device")] + #[tokio::test] + async fn open_virtual_empty_storages_rejected() { + use crate::transport::virtual_device::config::VirtualDeviceConfig; + + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Empty".into(), + serial: "empty-001".into(), + storages: vec![], + supports_rename: false, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + }; + + let result = MtpDevice::builder().open_virtual(config).await; + match result { + Err(err) => assert!( + err.to_string().contains("at least one storage"), + "unexpected error: {}", + err + ), + Ok(_) => panic!("expected error for empty storages"), + } + } + + #[tokio::test] + #[ignore] // Requires real MTP device + async fn real_device_operations() { + let device = MtpDevice::open_first().await.unwrap(); + println!("Connected to: {}", device.device_info().model); + for storage in device.storages().await.unwrap() { + println!("Storage: {}", storage.info().description); + } + device.close().await.unwrap(); + } +} diff --git a/vendor/mtp-rs/src/mtp/event.rs b/vendor/mtp-rs/src/mtp/event.rs new file mode 100644 index 0000000..7cbdba9 --- /dev/null +++ b/vendor/mtp-rs/src/mtp/event.rs @@ -0,0 +1,182 @@ +//! Device events. + +use crate::ptp::{EventCode, EventContainer, ObjectHandle, StorageId}; + +/// Events from an MTP device. +#[derive(Debug, Clone)] +pub enum DeviceEvent { + /// A new object was added. + ObjectAdded { + /// Handle of the new object. + handle: ObjectHandle, + }, + + /// An object was removed. + ObjectRemoved { + /// Handle of the removed object. + handle: ObjectHandle, + }, + + /// A storage was added (e.g., SD card inserted). + StoreAdded { + /// ID of the new storage. + storage_id: StorageId, + }, + + /// A storage was removed. + StoreRemoved { + /// ID of the removed storage. + storage_id: StorageId, + }, + + /// Storage info changed (e.g., free space). + StorageInfoChanged { + /// ID of the storage that changed. + storage_id: StorageId, + }, + + /// Object info changed. + ObjectInfoChanged { + /// Handle of the object that changed. + handle: ObjectHandle, + }, + + /// Device info changed. + DeviceInfoChanged, + + /// Device is being reset. + DeviceReset, + + /// Unknown event. + Unknown { + /// Raw event code. + code: u16, + /// Event parameters. + params: [u32; 3], + }, +} + +impl DeviceEvent { + /// Parse from an event container. + #[must_use] + pub fn from_container(container: &EventContainer) -> Self { + match container.code { + EventCode::ObjectAdded => DeviceEvent::ObjectAdded { + handle: ObjectHandle(container.params[0]), + }, + EventCode::ObjectRemoved => DeviceEvent::ObjectRemoved { + handle: ObjectHandle(container.params[0]), + }, + EventCode::StoreAdded => DeviceEvent::StoreAdded { + storage_id: StorageId(container.params[0]), + }, + EventCode::StoreRemoved => DeviceEvent::StoreRemoved { + storage_id: StorageId(container.params[0]), + }, + EventCode::StorageInfoChanged => DeviceEvent::StorageInfoChanged { + storage_id: StorageId(container.params[0]), + }, + EventCode::ObjectInfoChanged => DeviceEvent::ObjectInfoChanged { + handle: ObjectHandle(container.params[0]), + }, + EventCode::DeviceInfoChanged => DeviceEvent::DeviceInfoChanged, + // All other codes (including Unknown and unhandled known codes like DevicePropChanged) + other => DeviceEvent::Unknown { + code: other.into(), + params: container.params, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_parsing() { + // Events with object handle param + for (code, expected_handle) in [ + (EventCode::ObjectAdded, 42), + (EventCode::ObjectRemoved, 123), + (EventCode::ObjectInfoChanged, 99), + ] { + let container = EventContainer { + code, + transaction_id: 0, + params: [expected_handle, 0, 0], + }; + let event = DeviceEvent::from_container(&container); + let handle = match event { + DeviceEvent::ObjectAdded { handle } => handle, + DeviceEvent::ObjectRemoved { handle } => handle, + DeviceEvent::ObjectInfoChanged { handle } => handle, + _ => panic!("Unexpected event type"), + }; + assert_eq!(handle, ObjectHandle(expected_handle)); + } + + // Events with storage ID param + for (code, expected_id) in [ + (EventCode::StoreAdded, 0x00010001), + (EventCode::StoreRemoved, 0x00010002), + (EventCode::StorageInfoChanged, 0x00010001), + ] { + let container = EventContainer { + code, + transaction_id: 0, + params: [expected_id, 0, 0], + }; + let event = DeviceEvent::from_container(&container); + let storage_id = match event { + DeviceEvent::StoreAdded { storage_id } => storage_id, + DeviceEvent::StoreRemoved { storage_id } => storage_id, + DeviceEvent::StorageInfoChanged { storage_id } => storage_id, + _ => panic!("Unexpected event type"), + }; + assert_eq!(storage_id, StorageId(expected_id)); + } + + // DeviceInfoChanged (no params) + let container = EventContainer { + code: EventCode::DeviceInfoChanged, + transaction_id: 0, + params: [0, 0, 0], + }; + assert!(matches!( + DeviceEvent::from_container(&container), + DeviceEvent::DeviceInfoChanged + )); + } + + #[test] + fn unknown_events() { + // Explicit Unknown code + let container = EventContainer { + code: EventCode::Unknown(0x9999), + transaction_id: 0, + params: [1, 2, 3], + }; + match DeviceEvent::from_container(&container) { + DeviceEvent::Unknown { code, params } => { + assert_eq!(code, 0x9999); + assert_eq!(params, [1, 2, 3]); + } + _ => panic!("Expected Unknown event"), + } + + // Known EventCode without DeviceEvent variant (DevicePropChanged) + let container = EventContainer { + code: EventCode::DevicePropChanged, + transaction_id: 0, + params: [100, 0, 0], + }; + match DeviceEvent::from_container(&container) { + DeviceEvent::Unknown { code, params } => { + assert_eq!(code, 0x4006); + assert_eq!(params[0], 100); + } + _ => panic!("Expected Unknown event"), + } + } +} diff --git a/vendor/mtp-rs/src/mtp/mod.rs b/vendor/mtp-rs/src/mtp/mod.rs new file mode 100644 index 0000000..81220fa --- /dev/null +++ b/vendor/mtp-rs/src/mtp/mod.rs @@ -0,0 +1,45 @@ +//! High-level MTP (Media Transfer Protocol) API for Android devices and media players. +//! +//! This module provides a convenient, batteries-included API for common file transfer +//! operations. Use this module when: +//! +//! - Working with Android phones and tablets +//! - You want simple file listing, upload, and download +//! - You need storage enumeration and device info +//! - You don't need camera-specific features (capture, live view, etc.) +//! +//! ## When to use `ptp` instead +//! +//! Use the lower-level [`crate::ptp`] module when you need: +//! - Direct control over PTP operations and transactions +//! - Camera-specific functionality +//! - Custom protocol extensions +//! - Access to raw response codes and error details +//! +//! ## Quick example +//! +//! ```rust,no_run +//! use mtp_rs::mtp::MtpDevice; +//! +//! # async fn example() -> Result<(), mtp_rs::Error> { +//! let device = MtpDevice::open_first().await?; +//! for storage in device.storages().await? { +//! for obj in storage.list_objects(None).await? { +//! println!("{}", obj.filename); +//! } +//! } +//! # Ok(()) +//! # } +//! ``` + +mod device; +mod event; +mod object; +mod storage; +mod stream; + +pub use device::{MtpDevice, MtpDeviceBuilder, MtpDeviceInfo}; +pub use event::DeviceEvent; +pub use object::NewObjectInfo; +pub use storage::{ObjectListing, Storage}; +pub use stream::{FileDownload, Progress, DEFAULT_CANCEL_TIMEOUT}; diff --git a/vendor/mtp-rs/src/mtp/object.rs b/vendor/mtp-rs/src/mtp/object.rs new file mode 100644 index 0000000..bc4a0e3 --- /dev/null +++ b/vendor/mtp-rs/src/mtp/object.rs @@ -0,0 +1,194 @@ +//! Object-related types for MTP. + +use crate::ptp::{AssociationType, DateTime, ObjectFormatCode, ObjectInfo as PtpObjectInfo}; + +/// Information needed to create a new object. +#[derive(Debug, Clone)] +pub struct NewObjectInfo { + /// Filename (max 254 characters, no /, \, or null bytes) + pub filename: String, + /// File size in bytes (must match actual data sent) + pub size: u64, + /// Object format (auto-detected from extension if None) + pub format: Option, + /// Modification time + pub modified: Option, +} + +impl NewObjectInfo { + /// Create info for a file. Format auto-detected from extension. + #[must_use] + pub fn file(filename: impl Into, size: u64) -> Self { + let filename = filename.into(); + let format = detect_format_from_filename(&filename); + Self { + filename, + size, + format: Some(format), + modified: None, + } + } + + /// Create info for a folder. + #[must_use] + pub fn folder(name: impl Into) -> Self { + Self { + filename: name.into(), + size: 0, + format: Some(ObjectFormatCode::Association), + modified: None, + } + } + + /// Create info with explicit format. + #[must_use] + pub fn with_format(filename: impl Into, size: u64, format: ObjectFormatCode) -> Self { + Self { + filename: filename.into(), + size, + format: Some(format), + modified: None, + } + } + + /// Set modification time. + #[must_use] + pub fn with_modified(mut self, modified: DateTime) -> Self { + self.modified = Some(modified); + self + } + + /// Convert to PTP ObjectInfo for sending. + pub(crate) fn to_object_info(&self) -> PtpObjectInfo { + let format = self.format.unwrap_or(ObjectFormatCode::Undefined); + let is_folder = format == ObjectFormatCode::Association; + + PtpObjectInfo { + format, + size: self.size, + filename: self.filename.clone(), + modified: self.modified, + association_type: if is_folder { + AssociationType::GenericFolder + } else { + AssociationType::None + }, + ..Default::default() + } + } +} + +/// Detect format from filename extension. +fn detect_format_from_filename(filename: &str) -> ObjectFormatCode { + if let Some(ext) = filename.rsplit('.').next() { + ObjectFormatCode::from_extension(ext) + } else { + ObjectFormatCode::Undefined + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_object_info_file() { + let info = NewObjectInfo::file("test.mp3", 1000); + assert_eq!(info.filename, "test.mp3"); + assert_eq!(info.size, 1000); + assert_eq!(info.format, Some(ObjectFormatCode::Mp3)); + } + + #[test] + fn test_new_object_info_folder() { + let info = NewObjectInfo::folder("Music"); + assert_eq!(info.filename, "Music"); + assert_eq!(info.size, 0); + assert_eq!(info.format, Some(ObjectFormatCode::Association)); + } + + #[test] + fn test_format_detection() { + assert_eq!( + detect_format_from_filename("song.mp3"), + ObjectFormatCode::Mp3 + ); + assert_eq!( + detect_format_from_filename("photo.jpg"), + ObjectFormatCode::Jpeg + ); + assert_eq!( + detect_format_from_filename("video.mp4"), + ObjectFormatCode::Mp4Container + ); + assert_eq!( + detect_format_from_filename("unknown.xyz"), + ObjectFormatCode::Undefined + ); + } + + #[test] + fn test_with_format() { + let info = NewObjectInfo::with_format("document.bin", 500, ObjectFormatCode::Executable); + assert_eq!(info.filename, "document.bin"); + assert_eq!(info.size, 500); + assert_eq!(info.format, Some(ObjectFormatCode::Executable)); + } + + #[test] + fn test_with_modified() { + let dt = DateTime { + year: 2024, + month: 6, + day: 15, + hour: 10, + minute: 30, + second: 0, + }; + let info = NewObjectInfo::file("test.txt", 100).with_modified(dt); + assert_eq!(info.modified, Some(dt)); + } + + #[test] + fn test_to_object_info_file() { + let info = NewObjectInfo::file("test.mp3", 1000); + let ptp_info = info.to_object_info(); + + assert_eq!(ptp_info.format, ObjectFormatCode::Mp3); + assert_eq!(ptp_info.size, 1000); + assert_eq!(ptp_info.filename, "test.mp3"); + assert_eq!(ptp_info.association_type, AssociationType::None); + } + + #[test] + fn test_to_object_info_folder() { + let info = NewObjectInfo::folder("Music"); + let ptp_info = info.to_object_info(); + + assert_eq!(ptp_info.format, ObjectFormatCode::Association); + assert_eq!(ptp_info.size, 0); + assert_eq!(ptp_info.filename, "Music"); + assert_eq!(ptp_info.association_type, AssociationType::GenericFolder); + } + + #[test] + fn test_format_detection_case_insensitive() { + // The from_extension method is case-insensitive + assert_eq!( + detect_format_from_filename("SONG.MP3"), + ObjectFormatCode::Mp3 + ); + assert_eq!( + detect_format_from_filename("Photo.JPG"), + ObjectFormatCode::Jpeg + ); + } + + #[test] + fn test_format_detection_no_extension() { + assert_eq!( + detect_format_from_filename("noextension"), + ObjectFormatCode::Undefined + ); + } +} diff --git a/vendor/mtp-rs/src/mtp/storage.rs b/vendor/mtp-rs/src/mtp/storage.rs new file mode 100644 index 0000000..76ce654 --- /dev/null +++ b/vendor/mtp-rs/src/mtp/storage.rs @@ -0,0 +1,1112 @@ +//! Storage operations. + +use crate::mtp::object::NewObjectInfo; +use crate::mtp::stream::{FileDownload, Progress}; +use crate::ptp::{ObjectHandle, ObjectInfo, StorageId, StorageInfo}; +use crate::Error; +use bytes::Bytes; +use futures::Stream; +use std::ops::ControlFlow; +use std::sync::Arc; + +use super::device::MtpDeviceInner; + +/// An in-progress directory listing that yields [`ObjectInfo`] items one at a time. +/// +/// Created by [`Storage::list_objects_stream()`]. After `GetObjectHandles` completes, +/// the total count is known immediately. Each call to [`next()`](Self::next) fetches +/// one `GetObjectInfo` from USB, so the consumer can report progress (e.g., +/// "Loading files (42 of 500)...") as items arrive. +/// +/// # Important +/// +/// The MTP session is busy while this listing is active. You must consume +/// all items (or drop the listing) before calling other storage methods. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::mtp::MtpDevice; +/// +/// # async fn example() -> Result<(), mtp_rs::Error> { +/// # let device = MtpDevice::open_first().await?; +/// # let storages = device.storages().await?; +/// # let storage = &storages[0]; +/// let mut listing = storage.list_objects_stream(None).await?; +/// println!("Loading {} files...", listing.total()); +/// +/// while let Some(result) = listing.next().await { +/// let info = result?; +/// println!("[{}/{}] {}", listing.fetched(), listing.total(), info.filename); +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct ObjectListing { + inner: Arc, + handles: Vec, + /// Index of the next handle to fetch. + cursor: usize, + /// Parent filter: if Some, only items matching this parent are yielded. + parent_filter: Option, +} + +/// Describes how to filter objects by parent handle. +enum ParentFilter { + /// Accept objects whose parent matches exactly. + Exact(ObjectHandle), + /// Android root: accept parent 0 or 0xFFFFFFFF. + AndroidRoot, +} + +impl ObjectListing { + /// Total number of object handles returned by the device. + /// + /// When a parent filter is active (e.g., Fuji devices that return all objects + /// for root), some items may be skipped, so the actual yielded count can be lower. + #[must_use] + pub fn total(&self) -> usize { + self.handles.len() + } + + /// Number of handles processed so far (including filtered-out items). + #[must_use] + pub fn fetched(&self) -> usize { + self.cursor + } + + /// Fetch the next object from the device. + /// + /// Returns `None` when all handles have been processed. + /// Items that don't match the parent filter are silently skipped. + pub async fn next(&mut self) -> Option> { + loop { + if self.cursor >= self.handles.len() { + return None; + } + + let handle = self.handles[self.cursor]; + self.cursor += 1; + + let mut info = match self.inner.session.get_object_info_full(handle).await { + Ok(info) => info, + Err(e) => return Some(Err(e)), + }; + info.handle = handle; + + // Apply parent filter if present + if let Some(filter) = &self.parent_filter { + let matches = match filter { + ParentFilter::Exact(expected) => info.parent == *expected, + ParentFilter::AndroidRoot => info.parent.0 == 0 || info.parent.0 == 0xFFFFFFFF, + }; + if !matches { + continue; + } + } + + return Some(Ok(info)); + } + } +} + +/// A storage location on an MTP device. +/// +/// `Storage` holds an `Arc` so it can outlive the original +/// `MtpDevice` and be used from multiple tasks. +pub struct Storage { + inner: Arc, + id: StorageId, + info: StorageInfo, +} + +impl Storage { + /// Create a new Storage (internal). + pub(crate) fn new(inner: Arc, id: StorageId, info: StorageInfo) -> Self { + Self { inner, id, info } + } + + #[must_use] + pub fn id(&self) -> StorageId { + self.id + } + + /// Storage information (cached, call refresh() to update). + #[must_use] + pub fn info(&self) -> &StorageInfo { + &self.info + } + + /// Refresh storage info from device (updates free space, etc.). + pub async fn refresh(&mut self) -> Result<(), Error> { + self.info = self.inner.session.get_storage_info(self.id).await?; + Ok(()) + } + + /// List objects in a folder (None = root), returning all results at once. + /// + /// For progress reporting during large listings, use + /// [`list_objects_stream()`](Self::list_objects_stream) instead. + /// + /// This method handles various device quirks: + /// - Root listing tries parent=0xFFFFFFFF first (fast path for Android, Kindle, etc.) + /// - Falls back to parent=0 only when the device rejects 0xFFFFFFFF + /// - Samsung devices: return InvalidObjectHandle for parent=0, so we fall back to recursive + /// - Fuji devices: return all objects for root, so we filter by parent handle + pub async fn list_objects( + &self, + parent: Option, + ) -> Result, Error> { + let mut listing = self.list_objects_stream(parent).await?; + let mut objects = Vec::with_capacity(listing.total()); + while let Some(result) = listing.next().await { + objects.push(result?); + } + Ok(objects) + } + + /// List objects in a folder as a streaming [`ObjectListing`]. + /// + /// Returns immediately after `GetObjectHandles` completes. The total count + /// is then known via [`ObjectListing::total()`], and each call to + /// [`ObjectListing::next()`] fetches one object's metadata from USB. + /// + /// For root listings (`parent=None`), tries `parent=0xFFFFFFFF` first — this + /// returns only root-level handles on Android, Kindle, and many other devices. + /// Falls back to `parent=0` only when the device rejects `0xFFFFFFFF` with an + /// error. An empty result from `0xFFFFFFFF` is treated as an empty storage, + /// not as a reason to fall back. + /// + /// This enables progress reporting (e.g., "Loading 42 of 500...") during + /// what would otherwise be a single blocking `list_objects()` call. + /// + /// Handles the same device quirks as [`list_objects()`](Self::list_objects). + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// # let device = MtpDevice::open_first().await?; + /// # let storages = device.storages().await?; + /// # let storage = &storages[0]; + /// let mut listing = storage.list_objects_stream(None).await?; + /// println!("Found {} items", listing.total()); + /// + /// while let Some(result) = listing.next().await { + /// let info = result?; + /// println!("[{}/{}] {}", listing.fetched(), listing.total(), info.filename); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list_objects_stream( + &self, + parent: Option, + ) -> Result { + // For root listings, try parent=0xFFFFFFFF first. Many devices (Android, + // Kindle, others) return only root-level handles for this value, while + // parent=0 returns every object on the storage. Fall back to parent=0 + // only when the device rejects 0xFFFFFFFF with an error. + if parent.is_none() { + let fast = self + .inner + .session + .get_object_handles(self.id, None, Some(ObjectHandle::ALL)) + .await; + + match fast { + Ok(handles) => { + return Ok(ObjectListing { + inner: Arc::clone(&self.inner), + handles, + cursor: 0, + parent_filter: Some(ParentFilter::AndroidRoot), + }); + } + Err(_) => { + // 0xFFFFFFFF rejected; fall through to parent=0 path + } + } + } + + let result = self + .inner + .session + .get_object_handles(self.id, None, parent) + .await; + + let handles = match result { + Ok(h) => h, + Err(Error::Protocol { + code: crate::ptp::ResponseCode::InvalidObjectHandle, + .. + }) if parent.is_none() => { + // Samsung fallback: use recursive listing and filter to root items + return self.list_objects_stream_samsung_fallback().await; + } + Err(e) => return Err(e), + }; + + let parent_filter = Some(ParentFilter::Exact(parent.unwrap_or(ObjectHandle::ROOT))); + + Ok(ObjectListing { + inner: Arc::clone(&self.inner), + handles, + cursor: 0, + parent_filter, + }) + } + + /// Samsung fallback returning a streaming [`ObjectListing`]. + async fn list_objects_stream_samsung_fallback(&self) -> Result { + let handles = self + .inner + .session + .get_object_handles(self.id, None, Some(ObjectHandle::ALL)) + .await?; + + Ok(ObjectListing { + inner: Arc::clone(&self.inner), + handles, + cursor: 0, + // Root items have parent 0 or 0xFFFFFFFF (depending on device) + parent_filter: Some(ParentFilter::AndroidRoot), + }) + } + + /// List objects recursively. + /// + /// This method automatically detects Android devices and uses manual traversal + /// for them, since Android's MTP implementation doesn't support the native + /// `ObjectHandle::ALL` recursive listing. + /// + /// For non-Android devices, it tries native recursive listing first and falls + /// back to manual traversal if the results look incomplete. + pub async fn list_objects_recursive( + &self, + parent: Option, + ) -> Result, Error> { + if self.inner.is_android() { + return self.list_objects_recursive_manual(parent).await; + } + + let native_result = self.list_objects_recursive_native(parent).await?; + + // Heuristic: if we only got folders and no files, native listing + // probably didn't work - fall back to manual traversal + let has_files = native_result.iter().any(|o| o.is_file()); + if !native_result.is_empty() && !has_files { + return self.list_objects_recursive_manual(parent).await; + } + + Ok(native_result) + } + + /// List objects recursively using native MTP recursive listing. + pub async fn list_objects_recursive_native( + &self, + parent: Option, + ) -> Result, Error> { + let recursive_parent = if parent.is_none() { + Some(ObjectHandle::ALL) + } else { + parent + }; + + let handles = self + .inner + .session + .get_object_handles(self.id, None, recursive_parent) + .await?; + + let mut objects = Vec::with_capacity(handles.len()); + for handle in handles { + let mut info = self.inner.session.get_object_info_full(handle).await?; + info.handle = handle; + objects.push(info); + } + Ok(objects) + } + + /// List objects recursively using manual folder traversal. + pub async fn list_objects_recursive_manual( + &self, + parent: Option, + ) -> Result, Error> { + let mut result = Vec::new(); + let mut folders_to_visit = vec![parent]; + + while let Some(current_parent) = folders_to_visit.pop() { + let objects = self.list_objects(current_parent).await?; + + for obj in objects { + if obj.is_folder() { + folders_to_visit.push(Some(obj.handle)); + } + result.push(obj); + } + } + + Ok(result) + } + + /// Get object metadata by handle. + /// + /// Files larger than 4 GB have their u64 size auto-resolved via + /// `GetObjectPropValue(ObjectSize)`; the standard `ObjectInfo` dataset + /// only encodes a u32 size which saturates at 4 GB - 1. + pub async fn get_object_info(&self, handle: ObjectHandle) -> Result { + let mut info = self.inner.session.get_object_info_full(handle).await?; + info.handle = handle; + Ok(info) + } + + // ========================================================================= + // Download operations + // ========================================================================= + + /// Download a file and return all bytes. + /// + /// For small to medium files where you want all the data in memory. + /// For large files or streaming to disk, use [`download_stream()`](Self::download_stream). + pub async fn download(&self, handle: ObjectHandle) -> Result, Error> { + self.inner.session.get_object(handle).await + } + + /// Download a partial file (byte range). + /// + /// Uses the standard `GetPartialObject` operation, which has a 32-bit offset. + /// Offsets beyond 4 GB will be silently truncated — for files larger than 4 GB, + /// use [`download_partial_64()`](Self::download_partial_64) instead. + pub async fn download_partial( + &self, + handle: ObjectHandle, + offset: u64, + size: u32, + ) -> Result, Error> { + self.inner + .session + .get_partial_object(handle, offset, size) + .await + } + + /// Download a partial file (byte range) with 64-bit offset support. + /// + /// Uses the Android/MTP extension `GetPartialObject64`, which supports offsets + /// beyond 4 GB. Only works on devices that advertise support for it (most modern + /// Android devices do); others return `OperationNotSupported`. + pub async fn download_partial_64( + &self, + handle: ObjectHandle, + offset: u64, + size: u32, + ) -> Result, Error> { + self.inner + .session + .get_partial_object_64(handle, offset, size) + .await + } + + pub async fn download_thumbnail(&self, handle: ObjectHandle) -> Result, Error> { + self.inner.session.get_thumb(handle).await + } + + /// Download a file as a stream (true USB streaming). + /// + /// Unlike [`download()`](Self::download), this method yields data chunks + /// directly from USB as they arrive, without buffering the entire file + /// in memory. Ideal for large files or when piping data to disk. + /// + /// # Important + /// + /// The MTP session is locked while the download is active. You must either + /// consume the entire download or call [`FileDownload::cancel()`] before + /// dropping it. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// use mtp_rs::ObjectHandle; + /// use tokio::io::AsyncWriteExt; + /// + /// # async fn example() -> Result<(), Box> { + /// # let device = MtpDevice::open_first().await?; + /// # let storages = device.storages().await?; + /// # let storage = &storages[0]; + /// # let handle = ObjectHandle(1); + /// let mut download = storage.download_stream(handle).await?; + /// println!("Downloading {} bytes...", download.size()); + /// + /// let mut file = tokio::fs::File::create("output.bin").await?; + /// while let Some(chunk) = download.next_chunk().await { + /// let bytes = chunk?; + /// file.write_all(&bytes).await?; + /// println!("Progress: {:.1}%", download.progress() * 100.0); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn download_stream(&self, handle: ObjectHandle) -> Result { + let info = self.get_object_info(handle).await?; + let size = info.size; + + let stream = self + .inner + .session + .execute_with_receive_stream(crate::ptp::OperationCode::GetObject, &[handle.0]) + .await?; + + Ok(FileDownload::new(size, stream)) + } + + // ========================================================================= + // Upload operations + // ========================================================================= + + /// Upload a file from a stream. + /// + /// The stream is consumed and all data is buffered before sending + /// (MTP protocol requires knowing the total size upfront). + /// + /// # Arguments + /// + /// * `parent` - Parent folder handle (None for root) + /// * `info` - Object metadata including filename and size + /// * `data` - Stream of data chunks to upload + pub async fn upload( + &self, + parent: Option, + info: NewObjectInfo, + data: S, + ) -> Result + where + S: Stream> + Unpin + Send, + { + self.upload_with_progress(parent, info, data, |_| ControlFlow::Continue(())) + .await + } + + /// Upload a file with progress callback. + /// + /// Progress is reported as data is read from the stream. Return + /// `ControlFlow::Break(())` from the callback to cancel the upload. + pub async fn upload_with_progress( + &self, + parent: Option, + info: NewObjectInfo, + data: S, + mut on_progress: F, + ) -> Result + where + S: Stream> + Unpin + Send, + F: FnMut(Progress) -> ControlFlow<()> + Send, + { + use futures::StreamExt; + + let total_size = info.size; + let object_info = info.to_object_info(); + let parent_handle = parent.unwrap_or(ObjectHandle::ROOT); + + let (_, _, handle) = self + .inner + .session + .send_object_info(self.id, parent_handle, &object_info) + .await?; + + // Wrap the stream to report progress and support cancellation. + let mut bytes_sent = 0u64; + let progress_stream = data.map(move |chunk_result| { + let chunk = chunk_result?; + bytes_sent += chunk.len() as u64; + let progress = Progress { + bytes_transferred: bytes_sent, + total_bytes: Some(total_size), + }; + if let ControlFlow::Break(()) = on_progress(progress) { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); + } + Ok(chunk) + }); + + self.inner + .session + .send_object_stream(total_size, progress_stream) + .await + .map_err(|e| match &e { + Error::Io(io_err) if io_err.kind() == std::io::ErrorKind::Interrupted => { + Error::Cancelled + } + _ => e, + })?; + + Ok(handle) + } + + // ========================================================================= + // Folder and object management + // ========================================================================= + + pub async fn create_folder( + &self, + parent: Option, + name: &str, + ) -> Result { + let info = NewObjectInfo::folder(name); + let object_info = info.to_object_info(); + let parent_handle = parent.unwrap_or(ObjectHandle::ROOT); + + let (_, _, handle) = self + .inner + .session + .send_object_info(self.id, parent_handle, &object_info) + .await?; + + Ok(handle) + } + + pub async fn delete(&self, handle: ObjectHandle) -> Result<(), Error> { + self.inner.session.delete_object(handle).await + } + + /// Move an object to a different folder. + pub async fn move_object( + &self, + handle: ObjectHandle, + new_parent: ObjectHandle, + new_storage: Option, + ) -> Result<(), Error> { + let storage = new_storage.unwrap_or(self.id); + self.inner + .session + .move_object(handle, storage, new_parent) + .await + } + + pub async fn copy_object( + &self, + handle: ObjectHandle, + new_parent: ObjectHandle, + new_storage: Option, + ) -> Result { + let storage = new_storage.unwrap_or(self.id); + self.inner + .session + .copy_object(handle, storage, new_parent) + .await + } + + /// Rename an object (file or folder). + /// + /// Not all devices support renaming. Use `MtpDevice::supports_rename()` + /// to check if this operation is available. + pub async fn rename(&self, handle: ObjectHandle, new_name: &str) -> Result<(), Error> { + self.inner.session.rename_object(handle, new_name).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::{ + pack_u16, pack_u32, pack_u32_array, ContainerType, DateTime, DeviceInfo, ObjectFormatCode, + OperationCode, PtpSession, ResponseCode, StorageInfo, + }; + use crate::transport::mock::MockTransport; + + // -- Test helpers (same protocol-level helpers as session tests) ----------- + + fn mock_transport() -> (Arc, Arc) { + let mock = Arc::new(MockTransport::new()); + let transport: Arc = Arc::clone(&mock) as _; + (transport, mock) + } + + fn ok_response(tx_id: u32) -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(12)); + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf + } + + fn error_response(tx_id: u32, code: ResponseCode) -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(12)); + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf + } + + fn data_container(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { + let len = 12 + payload.len(); + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf.extend_from_slice(payload); + buf + } + + // -- Storage-level helpers ------------------------------------------------ + + /// Build a Storage backed by a mock transport for testing. + /// + /// Queues the OpenSession response automatically. The caller must queue + /// further responses before calling list methods. + async fn mock_storage( + transport: Arc, + vendor_extension_desc: &str, + ) -> Storage { + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let inner = Arc::new(MtpDeviceInner { + session, + device_info: DeviceInfo { + vendor_extension_desc: vendor_extension_desc.to_string(), + ..DeviceInfo::default() + }, + }); + Storage::new(inner, StorageId(1), StorageInfo::default()) + } + + /// Build a minimal ObjectInfo binary payload with a given filename and parent. + fn object_info_bytes(filename: &str, parent: u32) -> Vec { + let info = ObjectInfo { + storage_id: StorageId(1), + format: ObjectFormatCode::Jpeg, + parent: ObjectHandle(parent), + filename: filename.to_string(), + created: Some(DateTime { + year: 2024, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + }), + ..ObjectInfo::default() + }; + info.to_bytes().unwrap() + } + + /// Queue GetObjectHandles response (data + ok) for a given transaction ID. + fn queue_handles(mock: &MockTransport, tx_id: u32, handles: &[u32]) { + let data = pack_u32_array(handles); + mock.queue_response(data_container( + tx_id, + OperationCode::GetObjectHandles, + &data, + )); + mock.queue_response(ok_response(tx_id)); + } + + /// Queue GetObjectInfo response (data + ok) for a given transaction ID. + fn queue_object_info(mock: &MockTransport, tx_id: u32, filename: &str, parent: u32) { + let data = object_info_bytes(filename, parent); + mock.queue_response(data_container(tx_id, OperationCode::GetObjectInfo, &data)); + mock.queue_response(ok_response(tx_id)); + } + + // -- Tests ---------------------------------------------------------------- + + #[tokio::test] + async fn stream_returns_objects_with_correct_counters() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[10, 20, 30]); + queue_object_info(&mock, 2, "photo.jpg", 0); + queue_object_info(&mock, 3, "video.mp4", 0); + queue_object_info(&mock, 4, "notes.txt", 0); + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 3); + assert_eq!(listing.fetched(), 0); + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "photo.jpg"); + assert_eq!(first.handle, ObjectHandle(10)); + assert_eq!(listing.fetched(), 1); + + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "video.mp4"); + assert_eq!(second.handle, ObjectHandle(20)); + assert_eq!(listing.fetched(), 2); + + let third = listing.next().await.unwrap().unwrap(); + assert_eq!(third.filename, "notes.txt"); + assert_eq!(third.handle, ObjectHandle(30)); + assert_eq!(listing.fetched(), 3); + + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_empty_directory() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_handles(&mock, 1, &[]); + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 0); + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_filters_by_parent() { + // Root fast path (0xFFFFFFFF) uses AndroidRoot filter, which rejects + // objects whose parent is neither 0 nor 0xFFFFFFFF. + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[10, 20, 30]); + queue_object_info(&mock, 2, "root_file.jpg", 0); // parent=ROOT, included + queue_object_info(&mock, 3, "nested.jpg", 99); // parent=99, filtered out + queue_object_info(&mock, 4, "another_root.txt", 0); // parent=ROOT, included + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 3); // Total handles from device + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "root_file.jpg"); + assert_eq!(listing.fetched(), 1); + + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "another_root.txt"); + assert_eq!(listing.fetched(), 3); // Processed all 3 (including filtered one) + + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_root_accepts_both_parent_values() { + // Root fast path uses AndroidRoot filter: accepts parent 0 or 0xFFFFFFFF + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[10, 20, 30]); + queue_object_info(&mock, 2, "dcim", 0); // parent=0, root + queue_object_info(&mock, 3, "download", 0xFFFFFFFF); // parent=ALL, also root + queue_object_info(&mock, 4, "nested", 42); // not root + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "dcim"); + + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "download"); + + assert!(listing.next().await.is_none()); // "nested" filtered out + } + + #[tokio::test] + async fn stream_subfolder_listing() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let parent_handle = 42u32; + queue_handles(&mock, 1, &[100, 101]); + queue_object_info(&mock, 2, "IMG_001.jpg", parent_handle); + queue_object_info(&mock, 3, "IMG_002.jpg", parent_handle); + + let storage = mock_storage(transport, "").await; + let mut listing = storage + .list_objects_stream(Some(ObjectHandle(parent_handle))) + .await + .unwrap(); + + assert_eq!(listing.total(), 2); + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "IMG_001.jpg"); + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "IMG_002.jpg"); + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_propagates_mid_listing_error() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[10, 20]); + queue_object_info(&mock, 2, "good.jpg", 0); + // Handle 20: device returns error instead of object info + mock.queue_response(error_response(3, ResponseCode::InvalidObjectHandle)); + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "good.jpg"); + + let second = listing.next().await.unwrap(); + assert!(second.is_err()); + } + + #[tokio::test] + async fn list_objects_matches_stream_collect() { + // Verify list_objects() returns identical results to collecting the stream + let (transport1, mock1) = mock_transport(); + let (transport2, mock2) = mock_transport(); + + for mock in [&mock1, &mock2] { + mock.queue_response(ok_response(0)); // OpenSession + queue_handles(mock, 1, &[10, 20]); + queue_object_info(mock, 2, "a.jpg", 0); + queue_object_info(mock, 3, "b.txt", 0); + } + + let storage1 = mock_storage(transport1, "").await; + let storage2 = mock_storage(transport2, "").await; + + let all_at_once = storage1.list_objects(None).await.unwrap(); + + let mut listing = storage2.list_objects_stream(None).await.unwrap(); + let mut streamed = Vec::new(); + while let Some(result) = listing.next().await { + streamed.push(result.unwrap()); + } + + assert_eq!(all_at_once.len(), streamed.len()); + for (a, b) in all_at_once.iter().zip(streamed.iter()) { + assert_eq!(a.filename, b.filename); + assert_eq!(a.handle, b.handle); + } + } + + // -- Root listing fast-path / fallback tests -------------------------------- + + #[tokio::test] + async fn stream_root_falls_back_on_error() { + // When 0xFFFFFFFF is rejected, falls through to parent=0 with Exact(ROOT) + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Fast path (0xFFFFFFFF): device rejects with InvalidObjectHandle + mock.queue_response(error_response(1, ResponseCode::InvalidObjectHandle)); + + // Fallback path (parent=0): returns root objects + queue_handles(&mock, 2, &[10, 20]); + queue_object_info(&mock, 3, "root.jpg", 0); + queue_object_info(&mock, 4, "nested.jpg", 99); // filtered by Exact(ROOT) + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 2); + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "root.jpg"); + + // nested.jpg has parent=99, filtered out by Exact(ROOT) + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_root_empty_is_not_fallback() { + // Empty Ok([]) from 0xFFFFFFFF is a valid empty storage, not a fallback trigger + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[]); // fast path returns empty + + let storage = mock_storage(transport, "").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 0); + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_kindle_root_uses_fast_path() { + // Non-Android device (Kindle) benefits from 0xFFFFFFFF fast path + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + queue_handles(&mock, 1, &[100, 101, 102]); + queue_object_info(&mock, 2, "documents", 0); + queue_object_info(&mock, 3, "system", 0); + queue_object_info(&mock, 4, "fonts", 0); + + let storage = mock_storage(transport, "microsoft.com/WMDRMPD:10.1").await; + let mut listing = storage.list_objects_stream(None).await.unwrap(); + + assert_eq!(listing.total(), 3); + + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "documents"); + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "system"); + let third = listing.next().await.unwrap().unwrap(); + assert_eq!(third.filename, "fonts"); + + assert!(listing.next().await.is_none()); + } + + #[tokio::test] + async fn stream_subfolder_skips_fast_path() { + // Subfolder listing should not attempt 0xFFFFFFFF; only one get_object_handles call + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let parent_handle = 50u32; + queue_handles(&mock, 1, &[200, 201]); + queue_object_info(&mock, 2, "child_a.txt", parent_handle); + queue_object_info(&mock, 3, "child_b.txt", parent_handle); + + let storage = mock_storage(transport, "").await; + let mut listing = storage + .list_objects_stream(Some(ObjectHandle(parent_handle))) + .await + .unwrap(); + + assert_eq!(listing.total(), 2); + let first = listing.next().await.unwrap().unwrap(); + assert_eq!(first.filename, "child_a.txt"); + let second = listing.next().await.unwrap().unwrap(); + assert_eq!(second.filename, "child_b.txt"); + assert!(listing.next().await.is_none()); + } + + // -- Full-size (>4 GB) resolution via GetObjectPropValue ------------------ + + /// Build an ObjectInfo payload with a specific `size`. Sizes > u32::MAX are + /// serialized as u32::MAX by `ObjectInfo::to_bytes`, matching real-device behavior. + fn object_info_bytes_with_size(filename: &str, parent: u32, size: u64) -> Vec { + let info = ObjectInfo { + storage_id: StorageId(1), + format: ObjectFormatCode::Jpeg, + parent: ObjectHandle(parent), + filename: filename.to_string(), + size, + ..ObjectInfo::default() + }; + info.to_bytes().unwrap() + } + + fn queue_object_info_with_size( + mock: &MockTransport, + tx_id: u32, + filename: &str, + parent: u32, + size: u64, + ) { + let data = object_info_bytes_with_size(filename, parent, size); + mock.queue_response(data_container(tx_id, OperationCode::GetObjectInfo, &data)); + mock.queue_response(ok_response(tx_id)); + } + + fn queue_object_size_prop(mock: &MockTransport, tx_id: u32, size: u64) { + let payload = crate::ptp::pack_u64(size); + mock.queue_response(data_container( + tx_id, + OperationCode::GetObjectPropValue, + &payload, + )); + mock.queue_response(ok_response(tx_id)); + } + + #[tokio::test] + async fn get_object_info_resolves_saturated_size() { + // Simulate a 5 GB file: ObjectInfo reports u32::MAX, GetObjectPropValue returns real u64. + const REAL_SIZE: u64 = 5 * 1024 * 1024 * 1024; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_object_info_with_size(&mock, 1, "big.mkv", 0, REAL_SIZE); + queue_object_size_prop(&mock, 2, REAL_SIZE); + + let storage = mock_storage(transport, "").await; + let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); + + assert_eq!(info.filename, "big.mkv"); + assert_eq!(info.size, REAL_SIZE, "size should be resolved to full u64"); + } + + #[tokio::test] + async fn get_object_info_skips_lookup_when_size_fits_u32() { + // Under u32::MAX: GetObjectPropValue must NOT be called. If it were, the test + // would hang or fail because we only queue one response. + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_object_info_with_size(&mock, 1, "small.jpg", 0, 1_000_000); + + let storage = mock_storage(transport, "").await; + let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); + + assert_eq!(info.size, 1_000_000); + } + + #[tokio::test] + async fn get_object_info_falls_back_when_prop_lookup_fails() { + // Device reports saturated size but doesn't support GetObjectPropValue. + // Caller should receive the saturated value rather than an error. + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_object_info_with_size(&mock, 1, "big.mkv", 0, 8 * 1024 * 1024 * 1024); + mock.queue_response(error_response(2, ResponseCode::OperationNotSupported)); + + let storage = mock_storage(transport, "").await; + let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); + + assert_eq!( + info.size, + u64::from(u32::MAX), + "should keep saturated u32::MAX when prop lookup fails" + ); + } + + #[tokio::test] + async fn get_object_info_exactly_u32_max_triggers_lookup() { + // A file whose real size happens to equal u32::MAX is ambiguous: we can't + // distinguish it from a saturated >4GB file. The lookup runs and returns the + // true size, which in this case happens to match. Verify we handle it correctly. + const REAL_SIZE: u64 = u32::MAX as u64; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_object_info_with_size(&mock, 1, "edge.bin", 0, REAL_SIZE); + queue_object_size_prop(&mock, 2, REAL_SIZE); + + let storage = mock_storage(transport, "").await; + let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); + + assert_eq!(info.size, REAL_SIZE); + } + + #[tokio::test] + async fn list_objects_stream_resolves_saturated_size() { + // Verify the fix also applies to the streaming listing path. + const REAL_SIZE: u64 = 6 * 1024 * 1024 * 1024; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + queue_handles(&mock, 1, &[10, 20]); + queue_object_info_with_size(&mock, 2, "small.jpg", 0, 500_000); + queue_object_info_with_size(&mock, 3, "huge.mkv", 0, REAL_SIZE); + queue_object_size_prop(&mock, 4, REAL_SIZE); + + let storage = mock_storage(transport, "").await; + let objects = storage.list_objects(None).await.unwrap(); + + assert_eq!(objects.len(), 2); + assert_eq!(objects[0].size, 500_000); + assert_eq!(objects[1].size, REAL_SIZE); + } +} diff --git a/vendor/mtp-rs/src/mtp/stream.rs b/vendor/mtp-rs/src/mtp/stream.rs new file mode 100644 index 0000000..2505680 --- /dev/null +++ b/vendor/mtp-rs/src/mtp/stream.rs @@ -0,0 +1,300 @@ +//! Streaming download/upload support. + +use crate::ptp::ReceiveStream; +use crate::Error; +use bytes::Bytes; +use std::ops::ControlFlow; + +/// Progress information for transfers. +#[derive(Debug, Clone)] +pub struct Progress { + /// Bytes transferred so far. + pub bytes_transferred: u64, + /// Total bytes (if known). + pub total_bytes: Option, +} + +impl Progress { + /// Progress as a percentage (0.0 to 100.0). + #[must_use] + pub fn percent(&self) -> f64 { + self.fraction() * 100.0 + } + + /// Progress as a fraction (0.0 to 1.0). + #[must_use] + pub fn fraction(&self) -> f64 { + self.total_bytes.map_or(1.0, |total| { + if total == 0 { + 1.0 + } else { + self.bytes_transferred as f64 / total as f64 + } + }) + } +} + +/// Default idle timeout for cancel drain operations. +/// +/// After sending the cancel control request, this is how long we wait +/// for additional data on each pipe before assuming it's clear. Matches +/// the 300ms timeout used by libmtp, which mirrors Windows behavior. +pub const DEFAULT_CANCEL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); + +/// A file download in progress with true USB streaming. +/// +/// This struct wraps the low-level `ReceiveStream` and provides convenient +/// methods for tracking progress. Data is streamed directly from USB as +/// chunks arrive, without buffering the entire file in memory. +/// +/// # Important +/// +/// The MTP session is locked while this download is active. You must either +/// consume the entire download or call [`cancel()`](Self::cancel) before +/// dropping it. Dropping mid-download without cancelling corrupts the USB +/// session. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::mtp::MtpDevice; +/// use mtp_rs::ObjectHandle; +/// use tokio::io::AsyncWriteExt; +/// +/// # async fn example() -> Result<(), Box> { +/// # let device = MtpDevice::open_first().await?; +/// # let storages = device.storages().await?; +/// # let storage = &storages[0]; +/// # let handle = ObjectHandle(1); +/// let mut download = storage.download_stream(handle).await?; +/// println!("Downloading {} bytes...", download.size()); +/// +/// # let mut file = tokio::fs::File::create("output.bin").await?; +/// while let Some(chunk) = download.next_chunk().await { +/// let bytes = chunk?; +/// file.write_all(&bytes).await?; +/// println!("Progress: {:.1}%", download.progress() * 100.0); +/// } +/// # Ok(()) +/// # } +/// ``` +#[must_use = "dropping a FileDownload mid-transfer corrupts the USB session; \ + consume it fully or call cancel()"] +pub struct FileDownload { + size: u64, + bytes_received: u64, + stream: ReceiveStream, +} + +impl FileDownload { + /// Create a new FileDownload wrapping a ReceiveStream. + pub(crate) fn new(size: u64, stream: ReceiveStream) -> Self { + Self { + size, + bytes_received: 0, + stream, + } + } + + /// Total file size in bytes. + #[must_use] + pub fn size(&self) -> u64 { + self.size + } + + /// Bytes received so far. + #[must_use] + pub fn bytes_received(&self) -> u64 { + self.bytes_received + } + + /// Progress as a fraction (0.0 to 1.0). + #[must_use] + pub fn progress(&self) -> f64 { + if self.size == 0 { + 1.0 + } else { + self.bytes_received as f64 / self.size as f64 + } + } + + /// Cancel the in-progress download. + /// + /// Uses the USB Still Image Class cancel mechanism to stop the transfer + /// and drain remaining data, leaving the session clean for the next + /// operation. + /// + /// The `idle_timeout` controls how long to wait during pipe drain before + /// assuming the pipe is clear. 1–2 seconds is typically sufficient. + /// + /// If the download is already complete, this is a no-op. + pub async fn cancel(&mut self, idle_timeout: std::time::Duration) -> Result<(), Error> { + self.stream.cancel(idle_timeout).await + } + + /// Get the next chunk of data from USB. + /// + /// Returns `None` when the download is complete. + pub async fn next_chunk(&mut self) -> Option> { + match self.stream.next_chunk().await { + Some(Ok(bytes)) => { + self.bytes_received += bytes.len() as u64; + Some(Ok(bytes)) + } + Some(Err(e)) => Some(Err(e)), + None => None, + } + } + + /// Consume the download and iterate with a progress callback. + /// + /// Calls `on_progress` after each chunk. Return `ControlFlow::Break(())` + /// to cancel the download. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// use mtp_rs::ObjectHandle; + /// use std::ops::ControlFlow; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// # let device = MtpDevice::open_first().await?; + /// # let storages = device.storages().await?; + /// # let storage = &storages[0]; + /// # let handle = ObjectHandle(1); + /// let download = storage.download_stream(handle).await?; + /// let data = download.collect_with_progress(|progress| { + /// println!("{:.1}%", progress.percent()); + /// ControlFlow::Continue(()) + /// }).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn collect_with_progress(mut self, mut on_progress: F) -> Result, Error> + where + F: FnMut(Progress) -> ControlFlow<()>, + { + let mut data = Vec::with_capacity(self.size as usize); + + while let Some(result) = self.next_chunk().await { + let chunk = result?; + data.extend_from_slice(&chunk); + + let progress = Progress { + bytes_transferred: self.bytes_received, + total_bytes: Some(self.size), + }; + + if let ControlFlow::Break(()) = on_progress(progress) { + self.stream.cancel(DEFAULT_CANCEL_TIMEOUT).await?; + return Err(Error::Cancelled); + } + } + + Ok(data) + } + + /// Collect all remaining data into a `Vec`. + /// + /// This consumes the download and buffers all data in memory. + pub async fn collect(self) -> Result, Error> { + self.stream.collect().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ops::ControlFlow; + + #[test] + fn progress_calculations() { + let cases = [ + (50, Some(100), 50.0, 0.5), + (100, Some(100), 100.0, 1.0), + (25, Some(100), 25.0, 0.25), + (0, Some(0), 100.0, 1.0), // Empty file + (50, None, 100.0, 1.0), // Unknown total defaults to complete + ]; + for (transferred, total, expected_pct, expected_frac) in cases { + let p = Progress { + bytes_transferred: transferred, + total_bytes: total, + }; + assert_eq!( + p.percent(), + expected_pct, + "percent failed for {transferred}/{total:?}" + ); + assert_eq!( + p.fraction(), + expected_frac, + "fraction failed for {transferred}/{total:?}" + ); + } + + // Large numbers + let large = Progress { + bytes_transferred: u64::MAX / 2, + total_bytes: Some(u64::MAX), + }; + let frac = large.fraction(); + assert!(frac > 0.49 && frac < 0.51); + } + + #[tokio::test] + async fn test_collect_with_progress_cancel_cleans_up() { + use crate::ptp::{ + pack_u16, pack_u32, ContainerType, ObjectHandle, OperationCode, PtpSession, + ResponseCode, + }; + use crate::transport::mock::MockTransport; + use std::sync::Arc; + + // Helper to build a response container + fn response(tx_id: u32, code: ResponseCode) -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(12)); + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf + } + + // Helper to build a data container + fn data(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { + let len = 12 + payload.len(); + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf.extend_from_slice(payload); + buf + } + + let mock = Arc::new(MockTransport::new()); + let transport: Arc = Arc::clone(&mock) as _; + mock.queue_response(response(0, ResponseCode::Ok)); // OpenSession + + let file_data = vec![1u8; 1000]; + let file_size = file_data.len() as u64; + mock.queue_response(data(1, OperationCode::GetObject, &file_data)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + let download = FileDownload::new(file_size, stream); + + // Break after first chunk + let result = download + .collect_with_progress(|_progress| ControlFlow::Break(())) + .await; + + assert!(matches!(result, Err(Error::Cancelled))); + + // Verify cancel_transfer was called with the correct transaction ID + let cancel_calls = mock.get_cancel_calls(); + assert_eq!(cancel_calls, vec![1]); + } +} diff --git a/vendor/mtp-rs/src/ptp/codes.rs b/vendor/mtp-rs/src/ptp/codes.rs new file mode 100644 index 0000000..96282e4 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/codes.rs @@ -0,0 +1,660 @@ +//! MTP/PTP protocol operation, response, event, and format codes. +//! +//! This module defines the standard codes used in MTP/PTP communication: +//! - [`OperationCode`]: Commands sent to the device +//! - [`ResponseCode`]: Status codes returned by the device +//! - [`EventCode`]: Asynchronous events from the device +//! - [`ObjectFormatCode`]: File format identifiers + +use num_enum::{FromPrimitive, IntoPrimitive}; + +/// PTP operation codes (commands sent to device). +/// +/// These codes identify the operation being requested in a PTP command container. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum OperationCode { + /// Get device information (capabilities, manufacturer, etc.). + GetDeviceInfo = 0x1001, + /// Open a session with the device. + OpenSession = 0x1002, + /// Close the current session. + CloseSession = 0x1003, + /// Get list of storage IDs. + GetStorageIds = 0x1004, + /// Get information about a storage. + GetStorageInfo = 0x1005, + /// Get the number of objects in a storage/folder. + GetNumObjects = 0x1006, + /// Get list of object handles. + GetObjectHandles = 0x1007, + /// Get information about an object. + GetObjectInfo = 0x1008, + /// Download an object's data. + GetObject = 0x1009, + /// Get thumbnail for an object. + GetThumb = 0x100A, + /// Delete an object. + DeleteObject = 0x100B, + /// Send object metadata (before sending object data). + SendObjectInfo = 0x100C, + /// Send object data (after SendObjectInfo). + SendObject = 0x100D, + /// Initiate image capture on a camera. + InitiateCapture = 0x100E, + /// Get device property descriptor. + GetDevicePropDesc = 0x1014, + /// Get current device property value. + GetDevicePropValue = 0x1015, + /// Set device property value. + SetDevicePropValue = 0x1016, + /// Reset device property to default value. + ResetDevicePropValue = 0x1017, + /// Move an object to a different location. + MoveObject = 0x1019, + /// Copy an object. + CopyObject = 0x101A, + /// Get partial object data (range request). Offset is u32, so capped at 4 GB. + GetPartialObject = 0x101B, + /// Get the value of an object property (MTP extension). + GetObjectPropValue = 0x9803, + /// Set the value of an object property (MTP extension). + SetObjectPropValue = 0x9804, + /// Get partial object data with 64-bit offset (Android/MTP extension). + /// Supports files larger than 4 GB. Parameters: handle, offset_lo, offset_hi, max_bytes. + GetPartialObject64 = 0x95C1, + /// Unknown or vendor-specific operation code. + #[num_enum(catch_all)] + Unknown(u16), +} + +/// PTP response codes (status returned by device). +/// +/// These codes indicate the result of an operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum ResponseCode { + /// Operation completed successfully. + Ok = 0x2001, + /// General unspecified error. + GeneralError = 0x2002, + /// Session is not open. + SessionNotOpen = 0x2003, + /// Invalid transaction ID. + InvalidTransactionId = 0x2004, + /// Operation is not supported. + OperationNotSupported = 0x2005, + /// Parameter is not supported. + ParameterNotSupported = 0x2006, + /// Transfer was incomplete. + IncompleteTransfer = 0x2007, + /// Invalid storage ID. + InvalidStorageId = 0x2008, + /// Invalid object handle. + InvalidObjectHandle = 0x2009, + /// Device property not supported. + DevicePropNotSupported = 0x200A, + /// Invalid object format code. + InvalidObjectFormatCode = 0x200B, + /// Storage is full. + StoreFull = 0x200C, + /// Object is write-protected. + ObjectWriteProtected = 0x200D, + /// Storage is read-only. + StoreReadOnly = 0x200E, + /// Access denied. + AccessDenied = 0x200F, + /// Object has no thumbnail. + NoThumbnailPresent = 0x2010, + /// Device is busy. + DeviceBusy = 0x2019, + /// Invalid parent object. + InvalidParentObject = 0x201A, + /// Invalid device property format. + InvalidDevicePropFormat = 0x201B, + /// Invalid device property value. + InvalidDevicePropValue = 0x201C, + /// Invalid parameter value. + InvalidParameter = 0x201D, + /// Session is already open. + SessionAlreadyOpen = 0x201E, + /// Transaction was cancelled. + TransactionCancelled = 0x201F, + /// Object is too large for the storage. + ObjectTooLarge = 0xA809, + /// Unknown or vendor-specific response code. + #[num_enum(catch_all)] + Unknown(u16), +} + +/// PTP event codes (asynchronous notifications from device). +/// +/// These codes identify events that the device sends asynchronously. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum EventCode { + /// Cancel an in-progress transaction. + CancelTransaction = 0x4001, + /// A new object was added. + ObjectAdded = 0x4002, + /// An object was removed. + ObjectRemoved = 0x4003, + /// A new storage was added. + StoreAdded = 0x4004, + /// A storage was removed. + StoreRemoved = 0x4005, + /// A device property changed. + DevicePropChanged = 0x4006, + /// Object information changed. + ObjectInfoChanged = 0x4007, + /// Device information changed. + DeviceInfoChanged = 0x4008, + /// Storage information changed. + StorageInfoChanged = 0x400C, + /// Capture operation completed. + CaptureComplete = 0x400D, + /// Unknown or vendor-specific event code. + #[num_enum(catch_all)] + Unknown(u16), +} + +/// PTP/MTP object format codes. +/// +/// These codes identify the format/type of objects stored on the device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum ObjectFormatCode { + /// Undefined/unknown format. + Undefined = 0x3000, + /// Association (folder/directory). + Association = 0x3001, + /// Script file. + Script = 0x3002, + /// Executable file. + Executable = 0x3003, + /// Plain text file. + Text = 0x3004, + /// HTML file. + Html = 0x3005, + /// DPOF (Digital Print Order Format). + Dpof = 0x3006, + /// AIFF audio. + Aiff = 0x3007, + /// WAV audio. + Wav = 0x3008, + /// MP3 audio. + Mp3 = 0x3009, + /// AVI video. + Avi = 0x300A, + /// MPEG video. + Mpeg = 0x300B, + /// ASF (Advanced Systems Format). + Asf = 0x300C, + /// JPEG image. + Jpeg = 0x3801, + /// TIFF image. + Tiff = 0x3804, + /// GIF image. + Gif = 0x3807, + /// BMP image. + Bmp = 0x3808, + /// PICT image. + Pict = 0x380A, + /// PNG image. + Png = 0x380B, + /// WMA audio. + WmaAudio = 0xB901, + /// OGG audio. + OggAudio = 0xB902, + /// AAC audio. + AacAudio = 0xB903, + /// FLAC audio. + FlacAudio = 0xB906, + /// WMV video. + WmvVideo = 0xB981, + /// MP4 container. + Mp4Container = 0xB982, + /// M4A audio. + M4aAudio = 0xB984, + /// Unknown or vendor-specific format code. + #[num_enum(catch_all)] + Unknown(u16), +} + +impl ObjectFormatCode { + /// Detect object format from file extension (case insensitive). + /// + /// Returns `Undefined` for unrecognized extensions. + #[must_use] + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { + // Text and documents + "txt" => ObjectFormatCode::Text, + "html" | "htm" => ObjectFormatCode::Html, + "dpof" => ObjectFormatCode::Dpof, + + // Audio formats + "aiff" | "aif" => ObjectFormatCode::Aiff, + "wav" => ObjectFormatCode::Wav, + "mp3" => ObjectFormatCode::Mp3, + "wma" => ObjectFormatCode::WmaAudio, + "ogg" | "oga" => ObjectFormatCode::OggAudio, + "aac" => ObjectFormatCode::AacAudio, + "flac" => ObjectFormatCode::FlacAudio, + "m4a" => ObjectFormatCode::M4aAudio, + + // Video formats + "avi" => ObjectFormatCode::Avi, + "mpg" | "mpeg" => ObjectFormatCode::Mpeg, + "asf" => ObjectFormatCode::Asf, + "wmv" => ObjectFormatCode::WmvVideo, + "mp4" | "m4v" => ObjectFormatCode::Mp4Container, + + // Image formats + "jpg" | "jpeg" => ObjectFormatCode::Jpeg, + "tif" | "tiff" => ObjectFormatCode::Tiff, + "gif" => ObjectFormatCode::Gif, + "bmp" => ObjectFormatCode::Bmp, + "pict" | "pct" => ObjectFormatCode::Pict, + "png" => ObjectFormatCode::Png, + + // Executables and scripts + "exe" | "dll" | "bin" => ObjectFormatCode::Executable, + "sh" | "bat" | "cmd" | "ps1" => ObjectFormatCode::Script, + + _ => ObjectFormatCode::Undefined, + } + } + + /// Check if this format is an audio format. + #[must_use] + pub fn is_audio(&self) -> bool { + matches!( + self, + ObjectFormatCode::Aiff + | ObjectFormatCode::Wav + | ObjectFormatCode::Mp3 + | ObjectFormatCode::WmaAudio + | ObjectFormatCode::OggAudio + | ObjectFormatCode::AacAudio + | ObjectFormatCode::FlacAudio + | ObjectFormatCode::M4aAudio + ) + } + + /// Check if this format is a video format. + #[must_use] + pub fn is_video(&self) -> bool { + matches!( + self, + ObjectFormatCode::Avi + | ObjectFormatCode::Mpeg + | ObjectFormatCode::Asf + | ObjectFormatCode::WmvVideo + | ObjectFormatCode::Mp4Container + ) + } + + /// Check if this format is an image format. + #[must_use] + pub fn is_image(&self) -> bool { + matches!( + self, + ObjectFormatCode::Jpeg + | ObjectFormatCode::Tiff + | ObjectFormatCode::Gif + | ObjectFormatCode::Bmp + | ObjectFormatCode::Pict + | ObjectFormatCode::Png + ) + } +} + +// Manual impl required because #[default] attribute conflicts with num_enum's #[num_enum(catch_all)] +#[allow(clippy::derivable_impls)] +impl Default for ObjectFormatCode { + fn default() -> Self { + ObjectFormatCode::Undefined + } +} + +/// MTP object property codes. +/// +/// These codes identify object properties that can be get/set via MTP operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum ObjectPropertyCode { + /// Storage ID containing the object. + StorageId = 0xDC01, + /// Object format code. + ObjectFormat = 0xDC02, + /// Protection status (read-only, etc.). + ProtectionStatus = 0xDC03, + /// Object size in bytes. + ObjectSize = 0xDC04, + /// Object filename (key property for renaming). + ObjectFileName = 0xDC07, + /// Date the object was created. + DateCreated = 0xDC08, + /// Date the object was last modified. + DateModified = 0xDC09, + /// Parent object handle. + ParentObject = 0xDC0B, + /// Display name of the object. + Name = 0xDC44, + /// Unknown or vendor-specific property code. + #[num_enum(catch_all)] + Unknown(u16), +} + +/// PTP property data type codes. +/// +/// These codes identify the data type of property values in property descriptors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum PropertyDataType { + /// Undefined type (0x0000). + Undefined = 0x0000, + /// Signed 8-bit integer (0x0001). + Int8 = 0x0001, + /// Unsigned 8-bit integer (0x0002). + Uint8 = 0x0002, + /// Signed 16-bit integer (0x0003). + Int16 = 0x0003, + /// Unsigned 16-bit integer (0x0004). + Uint16 = 0x0004, + /// Signed 32-bit integer (0x0005). + Int32 = 0x0005, + /// Unsigned 32-bit integer (0x0006). + Uint32 = 0x0006, + /// Signed 64-bit integer (0x0007). + Int64 = 0x0007, + /// Unsigned 64-bit integer (0x0008). + Uint64 = 0x0008, + /// Signed 128-bit integer (0x0009, rarely used). + Int128 = 0x0009, + /// Unsigned 128-bit integer (0x000A, rarely used). + Uint128 = 0x000A, + /// Unknown type code. + #[num_enum(catch_all)] + Unknown(u16), + /// UTF-16LE string (0xFFFF). + String = 0xFFFF, +} + +impl PropertyDataType { + /// Returns the byte size of this data type. + /// + /// Returns `None` for variable-length types (String) and unsupported types + /// (Undefined, Int128, Uint128, Unknown). + #[must_use] + pub fn byte_size(&self) -> Option { + match self { + PropertyDataType::Int8 | PropertyDataType::Uint8 => Some(1), + PropertyDataType::Int16 | PropertyDataType::Uint16 => Some(2), + PropertyDataType::Int32 | PropertyDataType::Uint32 => Some(4), + PropertyDataType::Int64 | PropertyDataType::Uint64 => Some(8), + PropertyDataType::Int128 | PropertyDataType::Uint128 => Some(16), + PropertyDataType::String + | PropertyDataType::Undefined + | PropertyDataType::Unknown(_) => None, + } + } +} + +/// Standard PTP device property codes (0x5000 range). +/// +/// These codes identify device-level properties that can be read or modified +/// using the GetDevicePropDesc, GetDevicePropValue, SetDevicePropValue, and +/// ResetDevicePropValue operations. +/// +/// Device properties are primarily used with digital cameras for settings +/// like ISO, aperture, shutter speed, etc. Most Android MTP devices do not +/// support device properties. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum DevicePropertyCode { + /// Undefined property. + Undefined = 0x5000, + /// Battery level (UINT8, 0-100 percent). + BatteryLevel = 0x5001, + /// Device functional mode (UINT16). + FunctionalMode = 0x5002, + /// Image size setting (String, e.g., "1920x1080"). + ImageSize = 0x5003, + /// Compression setting (UINT8). + CompressionSetting = 0x5004, + /// White balance (UINT16). + WhiteBalance = 0x5005, + /// RGB gain (String). + RgbGain = 0x5006, + /// F-Number/Aperture (UINT16, value/100 = f-stop). + FNumber = 0x5007, + /// Focal length (UINT32, units of 0.01mm). + FocalLength = 0x5008, + /// Focus distance (UINT16, mm). + FocusDistance = 0x5009, + /// Focus mode (UINT16). + FocusMode = 0x500A, + /// Exposure metering mode (UINT16). + ExposureMeteringMode = 0x500B, + /// Flash mode (UINT16). + FlashMode = 0x500C, + /// Exposure time/shutter speed (UINT32, units of 0.0001s). + ExposureTime = 0x500D, + /// Exposure program mode (UINT16). + ExposureProgramMode = 0x500E, + /// Exposure index/ISO (UINT16). + ExposureIndex = 0x500F, + /// Exposure bias compensation (INT16, units of 0.001 EV). + ExposureBiasCompensation = 0x5010, + /// Date and time (String, "YYYYMMDDThhmmss"). + DateTime = 0x5011, + /// Capture delay (UINT32, ms). + CaptureDelay = 0x5012, + /// Still capture mode (UINT16). + StillCaptureMode = 0x5013, + /// Contrast (UINT8). + Contrast = 0x5014, + /// Sharpness (UINT8). + Sharpness = 0x5015, + /// Digital zoom (UINT8). + DigitalZoom = 0x5016, + /// Effect mode (UINT16). + EffectMode = 0x5017, + /// Burst number (UINT16). + BurstNumber = 0x5018, + /// Burst interval (UINT16, ms). + BurstInterval = 0x5019, + /// Timelapse number (UINT16). + TimelapseNumber = 0x501A, + /// Timelapse interval (UINT32, ms). + TimelapseInterval = 0x501B, + /// Focus metering mode (UINT16). + FocusMeteringMode = 0x501C, + /// Upload URL (String). + UploadUrl = 0x501D, + /// Artist name (String). + Artist = 0x501E, + /// Copyright info (String). + CopyrightInfo = 0x501F, + /// Unknown/vendor-specific property code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_extension_detection() { + // Audio (representative samples) + assert_eq!( + ObjectFormatCode::from_extension("mp3"), + ObjectFormatCode::Mp3 + ); + assert_eq!( + ObjectFormatCode::from_extension("flac"), + ObjectFormatCode::FlacAudio + ); + assert_eq!( + ObjectFormatCode::from_extension("aif"), + ObjectFormatCode::Aiff + ); // alternate ext + + // Video + assert_eq!( + ObjectFormatCode::from_extension("mp4"), + ObjectFormatCode::Mp4Container + ); + assert_eq!( + ObjectFormatCode::from_extension("avi"), + ObjectFormatCode::Avi + ); + assert_eq!( + ObjectFormatCode::from_extension("mpg"), + ObjectFormatCode::Mpeg + ); // alternate ext + + // Image + assert_eq!( + ObjectFormatCode::from_extension("jpg"), + ObjectFormatCode::Jpeg + ); + assert_eq!( + ObjectFormatCode::from_extension("png"), + ObjectFormatCode::Png + ); + assert_eq!( + ObjectFormatCode::from_extension("tif"), + ObjectFormatCode::Tiff + ); // alternate ext + + // Text/Documents + assert_eq!( + ObjectFormatCode::from_extension("txt"), + ObjectFormatCode::Text + ); + assert_eq!( + ObjectFormatCode::from_extension("htm"), + ObjectFormatCode::Html + ); // alternate ext + + // Executables/Scripts + assert_eq!( + ObjectFormatCode::from_extension("exe"), + ObjectFormatCode::Executable + ); + assert_eq!( + ObjectFormatCode::from_extension("sh"), + ObjectFormatCode::Script + ); + + // Case insensitivity (one example suffices since .to_lowercase() is used) + assert_eq!( + ObjectFormatCode::from_extension("MP3"), + ObjectFormatCode::Mp3 + ); + + // Unknown extensions + assert_eq!( + ObjectFormatCode::from_extension("xyz"), + ObjectFormatCode::Undefined + ); + assert_eq!( + ObjectFormatCode::from_extension(""), + ObjectFormatCode::Undefined + ); + } + + // ==================== Format Category Tests ==================== + + #[test] + fn is_audio() { + assert!(ObjectFormatCode::Mp3.is_audio()); + assert!(ObjectFormatCode::FlacAudio.is_audio()); + assert!(!ObjectFormatCode::Jpeg.is_audio()); + assert!(!ObjectFormatCode::Mp4Container.is_audio()); + } + + #[test] + fn is_video() { + assert!(ObjectFormatCode::Mp4Container.is_video()); + assert!(ObjectFormatCode::Avi.is_video()); + assert!(!ObjectFormatCode::Mp3.is_video()); + assert!(!ObjectFormatCode::Jpeg.is_video()); + } + + #[test] + fn is_image() { + assert!(ObjectFormatCode::Jpeg.is_image()); + assert!(ObjectFormatCode::Png.is_image()); + assert!(!ObjectFormatCode::Mp3.is_image()); + assert!(!ObjectFormatCode::Mp4Container.is_image()); + } + + #[test] + fn format_categories_are_mutually_exclusive() { + let all_formats = [ + ObjectFormatCode::Undefined, + ObjectFormatCode::Association, + ObjectFormatCode::Script, + ObjectFormatCode::Executable, + ObjectFormatCode::Text, + ObjectFormatCode::Html, + ObjectFormatCode::Dpof, + ObjectFormatCode::Aiff, + ObjectFormatCode::Wav, + ObjectFormatCode::Mp3, + ObjectFormatCode::Avi, + ObjectFormatCode::Mpeg, + ObjectFormatCode::Asf, + ObjectFormatCode::Jpeg, + ObjectFormatCode::Tiff, + ObjectFormatCode::Gif, + ObjectFormatCode::Bmp, + ObjectFormatCode::Pict, + ObjectFormatCode::Png, + ObjectFormatCode::WmaAudio, + ObjectFormatCode::OggAudio, + ObjectFormatCode::AacAudio, + ObjectFormatCode::FlacAudio, + ObjectFormatCode::WmvVideo, + ObjectFormatCode::Mp4Container, + ObjectFormatCode::M4aAudio, + ]; + + for format in all_formats { + let categories = [format.is_audio(), format.is_video(), format.is_image()]; + let true_count = categories.iter().filter(|&&b| b).count(); + assert!( + true_count <= 1, + "{:?} belongs to multiple categories", + format + ); + } + } + + // ==================== PropertyDataType Tests ==================== + + #[test] + fn property_data_type_byte_size() { + // Fixed-size types + assert_eq!(PropertyDataType::Int8.byte_size(), Some(1)); + assert_eq!(PropertyDataType::Uint8.byte_size(), Some(1)); + assert_eq!(PropertyDataType::Int16.byte_size(), Some(2)); + assert_eq!(PropertyDataType::Uint16.byte_size(), Some(2)); + assert_eq!(PropertyDataType::Int32.byte_size(), Some(4)); + assert_eq!(PropertyDataType::Uint32.byte_size(), Some(4)); + assert_eq!(PropertyDataType::Int64.byte_size(), Some(8)); + assert_eq!(PropertyDataType::Uint64.byte_size(), Some(8)); + assert_eq!(PropertyDataType::Int128.byte_size(), Some(16)); + assert_eq!(PropertyDataType::Uint128.byte_size(), Some(16)); + + // Variable/undefined types + assert_eq!(PropertyDataType::String.byte_size(), None); + assert_eq!(PropertyDataType::Undefined.byte_size(), None); + assert_eq!(PropertyDataType::Unknown(0x1234).byte_size(), None); + } +} diff --git a/vendor/mtp-rs/src/ptp/container.rs b/vendor/mtp-rs/src/ptp/container.rs new file mode 100644 index 0000000..3b4ac2f --- /dev/null +++ b/vendor/mtp-rs/src/ptp/container.rs @@ -0,0 +1,724 @@ +//! MTP/PTP USB container format. +//! +//! This module implements the USB container format used for MTP/PTP communication. +//! All containers share a common 12-byte header followed by optional parameters or payload. +//! +//! ## Container format (little-endian) +//! +//! Header (12 bytes): +//! - Offset 0: Length (u32) - Total container size including header +//! - Offset 4: Type (u16) - Container type +//! - Offset 6: Code (u16) - Operation/Response/Event code +//! - Offset 8: TransactionID (u32) +//! +//! After header: parameters (each u32) or payload bytes. + +use super::{pack_u16, pack_u32, unpack_u16, unpack_u32, EventCode, OperationCode, ResponseCode}; + +/// Minimum container header size in bytes. +const HEADER_SIZE: usize = 12; + +/// Container type identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum ContainerType { + /// Command container (sent to device). + Command = 1, + /// Data container (bidirectional). + Data = 2, + /// Response container (from device). + Response = 3, + /// Event container (from device). + Event = 4, +} + +impl ContainerType { + /// Convert a raw u16 value to a ContainerType. + #[must_use] + pub fn from_code(code: u16) -> Option { + match code { + 1 => Some(ContainerType::Command), + 2 => Some(ContainerType::Data), + 3 => Some(ContainerType::Response), + 4 => Some(ContainerType::Event), + _ => None, + } + } + + /// Convert a ContainerType to its raw u16 value. + #[must_use] + pub fn to_code(self) -> u16 { + self as u16 + } +} + +/// Determine the container type from a raw buffer. +/// +/// Returns an error if the buffer is too small or contains an invalid container type. +pub fn container_type(buf: &[u8]) -> Result { + if buf.len() < HEADER_SIZE { + return Err(crate::Error::invalid_data(format!( + "container too small: need at least {} bytes, have {}", + HEADER_SIZE, + buf.len() + ))); + } + + let type_code = unpack_u16(&buf[4..6])?; + ContainerType::from_code(type_code) + .ok_or_else(|| crate::Error::invalid_data(format!("invalid container type: {}", type_code))) +} + +/// Command container sent to the device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandContainer { + /// Operation code for the command. + pub code: OperationCode, + /// Transaction ID for this operation. + pub transaction_id: u32, + /// Parameters (0-5 u32 values). + pub params: Vec, +} + +impl CommandContainer { + /// Serialize the command container to bytes. + pub fn to_bytes(&self) -> Vec { + let param_bytes = self.params.len() * 4; + let total_len = HEADER_SIZE + param_bytes; + + let mut buf = Vec::with_capacity(total_len); + + // Header + buf.extend_from_slice(&pack_u32(total_len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Command.to_code())); + buf.extend_from_slice(&pack_u16(self.code.into())); + buf.extend_from_slice(&pack_u32(self.transaction_id)); + + // Parameters + for ¶m in &self.params { + buf.extend_from_slice(&pack_u32(param)); + } + + buf + } +} + +/// Data container for transferring payload data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataContainer { + /// Operation code this data belongs to. + pub code: OperationCode, + /// Transaction ID for this operation. + pub transaction_id: u32, + /// Payload bytes. + pub payload: Vec, +} + +impl DataContainer { + /// Serialize the data container to bytes. + pub fn to_bytes(&self) -> Vec { + let total_len = HEADER_SIZE + self.payload.len(); + + let mut buf = Vec::with_capacity(total_len); + + // Header + buf.extend_from_slice(&pack_u32(total_len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + buf.extend_from_slice(&pack_u16(self.code.into())); + buf.extend_from_slice(&pack_u32(self.transaction_id)); + + // Payload + buf.extend_from_slice(&self.payload); + + buf + } + + /// Parse a data container from bytes. + pub fn from_bytes(buf: &[u8]) -> Result { + if buf.len() < HEADER_SIZE { + return Err(crate::Error::invalid_data(format!( + "data container too small: need at least {} bytes, have {}", + HEADER_SIZE, + buf.len() + ))); + } + + let length = unpack_u32(&buf[0..4])? as usize; + let type_code = unpack_u16(&buf[4..6])?; + let code = unpack_u16(&buf[6..8])?; + let transaction_id = unpack_u32(&buf[8..12])?; + + // Validate container type + if type_code != ContainerType::Data.to_code() { + return Err(crate::Error::invalid_data(format!( + "expected Data container type ({}), got {}", + ContainerType::Data.to_code(), + type_code + ))); + } + + // Validate length - must be at least header size and not exceed buffer + if length < HEADER_SIZE { + return Err(crate::Error::invalid_data(format!( + "data container length too small: {} < header size {}", + length, HEADER_SIZE + ))); + } + if buf.len() < length { + return Err(crate::Error::invalid_data(format!( + "data container length mismatch: header says {}, have {}", + length, + buf.len() + ))); + } + + // Extract payload + let payload = buf[HEADER_SIZE..length].to_vec(); + + Ok(DataContainer { + code: code.into(), + transaction_id, + payload, + }) + } +} + +/// Response container from the device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponseContainer { + /// Response code indicating success or failure. + pub code: ResponseCode, + /// Transaction ID this response corresponds to. + pub transaction_id: u32, + /// Response parameters (0-5 u32 values). + pub params: Vec, +} + +impl ResponseContainer { + /// Parse a response container from bytes. + pub fn from_bytes(buf: &[u8]) -> Result { + if buf.len() < HEADER_SIZE { + return Err(crate::Error::invalid_data(format!( + "response container too small: need at least {} bytes, have {}", + HEADER_SIZE, + buf.len() + ))); + } + + let length = unpack_u32(&buf[0..4])? as usize; + let type_code = unpack_u16(&buf[4..6])?; + let code = unpack_u16(&buf[6..8])?; + let transaction_id = unpack_u32(&buf[8..12])?; + + // Validate container type + if type_code != ContainerType::Response.to_code() { + return Err(crate::Error::invalid_data(format!( + "expected Response container type ({}), got {}", + ContainerType::Response.to_code(), + type_code + ))); + } + + // Validate length + if buf.len() < length { + return Err(crate::Error::invalid_data(format!( + "response container length mismatch: header says {}, have {}", + length, + buf.len() + ))); + } + + // Parse parameters + let param_bytes = length - HEADER_SIZE; + if param_bytes % 4 != 0 { + return Err(crate::Error::invalid_data(format!( + "response parameter bytes not aligned: {} bytes", + param_bytes + ))); + } + + let param_count = param_bytes / 4; + let mut params = Vec::with_capacity(param_count); + for i in 0..param_count { + let offset = HEADER_SIZE + i * 4; + params.push(unpack_u32(&buf[offset..])?); + } + + Ok(ResponseContainer { + code: code.into(), + transaction_id, + params, + }) + } + + /// Check if the response indicates success (Ok). + #[must_use] + pub fn is_ok(&self) -> bool { + self.code == ResponseCode::Ok + } +} + +/// Event container from the device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventContainer { + /// Event code identifying the event type. + pub code: EventCode, + /// Transaction ID (may be 0 for unsolicited events). + pub transaction_id: u32, + /// Event parameters (always exactly 3). + pub params: [u32; 3], +} + +impl EventContainer { + /// Serialize the event container to bytes. + /// + /// Produces a 24-byte container: 12-byte header + 3 u32 parameters. + pub fn to_bytes(&self) -> Vec { + const EVENT_SIZE: usize = HEADER_SIZE + 12; // 3 params + let mut buf = Vec::with_capacity(EVENT_SIZE); + buf.extend_from_slice(&pack_u32(EVENT_SIZE as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Event.to_code())); + buf.extend_from_slice(&pack_u16(self.code.into())); + buf.extend_from_slice(&pack_u32(self.transaction_id)); + for ¶m in &self.params { + buf.extend_from_slice(&pack_u32(param)); + } + buf + } + + /// Parse an event container from bytes. + /// + /// Events can have 0-3 parameters, so valid sizes are 12-24 bytes + /// (header + 0-3 u32 params). Missing parameters default to 0. + pub fn from_bytes(buf: &[u8]) -> Result { + const MAX_EVENT_SIZE: usize = HEADER_SIZE + 12; // 24 bytes max (3 params) + + if buf.len() < HEADER_SIZE { + return Err(crate::Error::invalid_data(format!( + "event container too small: need at least {} bytes, have {}", + HEADER_SIZE, + buf.len() + ))); + } + + let length = unpack_u32(&buf[0..4])? as usize; + let type_code = unpack_u16(&buf[4..6])?; + let code = unpack_u16(&buf[6..8])?; + let transaction_id = unpack_u32(&buf[8..12])?; + + // Validate container type + if type_code != ContainerType::Event.to_code() { + return Err(crate::Error::invalid_data(format!( + "expected Event container type ({}), got {}", + ContainerType::Event.to_code(), + type_code + ))); + } + + // Validate length: must be between 12 (header only) and 24 (header + 3 params) + if !(HEADER_SIZE..=MAX_EVENT_SIZE).contains(&length) { + return Err(crate::Error::invalid_data(format!( + "event container invalid size: expected 12-24, got {}", + length + ))); + } + + // Validate parameter alignment (must be multiple of 4 bytes after header) + let param_bytes = length - HEADER_SIZE; + if param_bytes % 4 != 0 { + return Err(crate::Error::invalid_data(format!( + "event parameter bytes not aligned: {} bytes", + param_bytes + ))); + } + + // Validate buffer has enough data + if buf.len() < length { + return Err(crate::Error::invalid_data(format!( + "event container buffer too small: need {}, have {}", + length, + buf.len() + ))); + } + + // Parse parameters (0-3), defaulting missing ones to 0 + let param_count = param_bytes / 4; + let param1 = if param_count >= 1 { + unpack_u32(&buf[12..16])? + } else { + 0 + }; + let param2 = if param_count >= 2 { + unpack_u32(&buf[16..20])? + } else { + 0 + }; + let param3 = if param_count >= 3 { + unpack_u32(&buf[20..24])? + } else { + 0 + }; + + Ok(EventContainer { + code: code.into(), + transaction_id, + params: [param1, param2, param3], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // --- ContainerType tests --- + + #[test] + fn container_type_conversions() { + for (code, ct) in [ + (1, ContainerType::Command), + (2, ContainerType::Data), + (3, ContainerType::Response), + (4, ContainerType::Event), + ] { + assert_eq!(ContainerType::from_code(code), Some(ct)); + assert_eq!(ct.to_code(), code); + } + for invalid in [0, 5, 0xFFFF] { + assert_eq!(ContainerType::from_code(invalid), None); + } + } + + #[test] + fn container_type_detection() { + // Build minimal containers and verify type detection + let containers: [(u16, ContainerType); 4] = [ + (1, ContainerType::Command), + (2, ContainerType::Data), + (3, ContainerType::Response), + (4, ContainerType::Event), + ]; + for (type_code, expected) in containers { + let mut bytes = vec![0x0C, 0x00, 0x00, 0x00]; // length = 12 + bytes.extend_from_slice(&type_code.to_le_bytes()); + bytes.extend_from_slice(&[0x00; 6]); // code + tx_id + assert_eq!(container_type(&bytes).unwrap(), expected); + } + + // Invalid type codes + for invalid in [0u16, 5] { + let mut bytes = vec![0x0C, 0x00, 0x00, 0x00]; + bytes.extend_from_slice(&invalid.to_le_bytes()); + bytes.extend_from_slice(&[0x00; 6]); + assert!(container_type(&bytes).is_err()); + } + + // Insufficient bytes + assert!(container_type(&[]).is_err()); + assert!(container_type(&[0x00; 11]).is_err()); + } + + // --- CommandContainer tests --- + + #[test] + fn command_container_serialization() { + let cmd = CommandContainer { + code: OperationCode::GetObjectHandles, + transaction_id: 10, + params: vec![0x00010001, 0x00000000, 0xFFFFFFFF], + }; + let bytes = cmd.to_bytes(); + assert_eq!(bytes.len(), 24); + assert_eq!(&bytes[0..4], &[0x18, 0x00, 0x00, 0x00]); // length = 24 + assert_eq!(&bytes[4..6], &[0x01, 0x00]); // type = Command + assert_eq!(&bytes[6..8], &[0x07, 0x10]); // code = 0x1007 + assert_eq!(&bytes[8..12], &[0x0A, 0x00, 0x00, 0x00]); // tx_id = 10 + assert_eq!(&bytes[12..16], &[0x01, 0x00, 0x01, 0x00]); // param1 + } + + // --- DataContainer tests --- + + #[test] + fn data_container_roundtrip() { + let original = DataContainer { + code: OperationCode::GetObject, + transaction_id: 100, + payload: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }; + let parsed = DataContainer::from_bytes(&original.to_bytes()).unwrap(); + assert_eq!(parsed, original); + + // Empty payload + let empty = DataContainer { + code: OperationCode::SendObject, + transaction_id: 5, + payload: vec![], + }; + assert_eq!(DataContainer::from_bytes(&empty.to_bytes()).unwrap(), empty); + } + + #[test] + fn data_container_errors() { + assert!(DataContainer::from_bytes(&[0x00; 11]).is_err()); // Too small + + // Wrong type + let mut bad_type = vec![0x0C, 0x00, 0x00, 0x00, 0x03, 0x00]; // Response type + bad_type.extend_from_slice(&[0x00; 6]); + assert!(DataContainer::from_bytes(&bad_type).is_err()); + + // Length > buffer + let mut truncated = vec![0x20, 0x00, 0x00, 0x00, 0x02, 0x00]; // claims 32 bytes + truncated.extend_from_slice(&[0x00; 6]); + assert!(DataContainer::from_bytes(&truncated).is_err()); + } + + // --- ResponseContainer tests --- + + #[test] + fn response_container_parsing() { + // OK response with params + let bytes = [ + 0x18, 0x00, 0x00, 0x00, // length = 24 + 0x03, 0x00, // type = Response + 0x01, 0x20, // code = OK + 0x02, 0x00, 0x00, 0x00, // tx_id = 2 + 0x01, 0x00, 0x01, 0x00, // param1 + 0x00, 0x00, 0x00, 0x00, // param2 + 0x05, 0x00, 0x00, 0x00, // param3 + ]; + let resp = ResponseContainer::from_bytes(&bytes).unwrap(); + assert_eq!(resp.code, ResponseCode::Ok); + assert!(resp.is_ok()); + assert_eq!(resp.params, vec![0x00010001, 0, 5]); + + // Error response + let err_bytes = [ + 0x0C, 0x00, 0x00, 0x00, 0x03, 0x00, 0x02, 0x20, // GeneralError + 0x03, 0x00, 0x00, 0x00, + ]; + let err_resp = ResponseContainer::from_bytes(&err_bytes).unwrap(); + assert_eq!(err_resp.code, ResponseCode::GeneralError); + assert!(!err_resp.is_ok()); + } + + #[test] + fn response_container_errors() { + assert!(ResponseContainer::from_bytes(&[0x00; 11]).is_err()); + + // Unaligned params (13 bytes = 12 header + 1) + let unaligned = [ + 0x0D, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x20, 0x01, 0x00, 0x00, 0x00, 0xFF, + ]; + assert!(ResponseContainer::from_bytes(&unaligned).is_err()); + } + + // --- EventContainer tests --- + + #[test] + fn event_container_to_bytes_roundtrip() { + let original = EventContainer { + code: EventCode::CancelTransaction, + transaction_id: 42, + params: [42, 0, 0], + }; + let bytes = original.to_bytes(); + assert_eq!(bytes.len(), 24); + let parsed = EventContainer::from_bytes(&bytes).unwrap(); + assert_eq!(parsed, original); + } + + #[test] + fn event_container_variable_params() { + // 0 params (12 bytes) + let zero = [ + 0x0C, 0x00, 0x00, 0x00, 0x04, 0x00, 0x08, 0x40, 0x00, 0x00, 0x00, 0x00, + ]; + let e0 = EventContainer::from_bytes(&zero).unwrap(); + assert_eq!(e0.code, EventCode::DeviceInfoChanged); + assert_eq!(e0.params, [0, 0, 0]); + + // 1 param (16 bytes) - common on Android + let one = [ + 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x2A, 0x00, + 0x00, 0x00, + ]; + let e1 = EventContainer::from_bytes(&one).unwrap(); + assert_eq!(e1.params, [42, 0, 0]); + + // 3 params (24 bytes) + let three = [ + 0x18, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x0A, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + ]; + let e3 = EventContainer::from_bytes(&three).unwrap(); + assert_eq!(e3.transaction_id, 10); + assert_eq!(e3.params, [1, 2, 3]); + } + + #[test] + fn event_container_errors() { + assert!(EventContainer::from_bytes(&[0x00; 11]).is_err()); + + // Length > 24 (too many params) + let too_long = [ + 0x1C, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert!(EventContainer::from_bytes(&too_long).is_err()); + + // Unaligned (14 bytes) + let unaligned = [ + 0x0E, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert!(EventContainer::from_bytes(&unaligned).is_err()); + } + + // --- Property-based tests --- + + fn valid_response_bytes(param_count: usize) -> impl Strategy> { + ( + any::(), + any::(), + prop::collection::vec(any::(), param_count..=param_count), + ) + .prop_map(move |(code, tx_id, params)| { + let len = HEADER_SIZE + params.len() * 4; + let mut bytes = Vec::with_capacity(len); + bytes.extend_from_slice(&pack_u32(len as u32)); + bytes.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + bytes.extend_from_slice(&pack_u16(code)); + bytes.extend_from_slice(&pack_u32(tx_id)); + for p in ¶ms { + bytes.extend_from_slice(&pack_u32(*p)); + } + bytes + }) + } + + proptest! { + #[test] + fn prop_container_type_roundtrip(code in 1u16..=4u16) { + let ct = ContainerType::from_code(code).unwrap(); + prop_assert_eq!(ct.to_code(), code); + } + + #[test] + fn prop_data_container_roundtrip( + code in any::(), + tx_id in any::(), + payload in prop::collection::vec(any::(), 0..500) + ) { + let original = DataContainer { + code: code.into(), + transaction_id: tx_id, + payload: payload.clone(), + }; + let parsed = DataContainer::from_bytes(&original.to_bytes()).unwrap(); + prop_assert_eq!(parsed, original); + } + + #[test] + fn prop_command_container_length( + code in any::(), + tx_id in any::(), + params in prop::collection::vec(any::(), 0..5) + ) { + let cmd = CommandContainer { + code: code.into(), + transaction_id: tx_id, + params: params.clone(), + }; + let bytes = cmd.to_bytes(); + let length = unpack_u32(&bytes[0..4]).unwrap() as usize; + prop_assert_eq!(length, HEADER_SIZE + params.len() * 4); + prop_assert_eq!(length, bytes.len()); + } + + #[test] + fn prop_response_container_parse(param_count in 0usize..=5usize) { + let strategy = valid_response_bytes(param_count); + proptest!(|(bytes in strategy)| { + let resp = ResponseContainer::from_bytes(&bytes).unwrap(); + prop_assert_eq!(resp.params.len(), param_count); + }); + } + + #[test] + fn prop_container_type_identification( + code in any::(), + tx_id in any::(), + payload in prop::collection::vec(any::(), 0..50) + ) { + let data = DataContainer { + code: code.into(), + transaction_id: tx_id, + payload, + }; + prop_assert_eq!(container_type(&data.to_bytes()).unwrap(), ContainerType::Data); + + let cmd = CommandContainer { + code: code.into(), + transaction_id: tx_id, + params: vec![], + }; + prop_assert_eq!(container_type(&cmd.to_bytes()).unwrap(), ContainerType::Command); + } + + // Adversarial tests + + #[test] + fn fuzz_data_container_length_underflow(fake_length in 0u32..12u32, tx_id: u32) { + let mut buf = fake_length.to_le_bytes().to_vec(); + buf.extend_from_slice(&2u16.to_le_bytes()); + buf.extend_from_slice(&0x1001u16.to_le_bytes()); + buf.extend_from_slice(&tx_id.to_le_bytes()); + prop_assert!(DataContainer::from_bytes(&buf).is_err()); + } + + #[test] + fn fuzz_event_container_invalid_length( + fake_length in prop::sample::select(vec![ + 0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Too small + 13, 14, 15, 17, 18, 19, 21, 22, 23, // Unaligned + 25, 26, 28, 32, 100, // Too large + ]), + tx_id: u32, + ) { + let mut buf = fake_length.to_le_bytes().to_vec(); + buf.extend_from_slice(&4u16.to_le_bytes()); + buf.extend_from_slice(&0x4002u16.to_le_bytes()); + buf.extend_from_slice(&tx_id.to_le_bytes()); + buf.extend_from_slice(&[0u8; 12]); // 3 params + prop_assert!(EventContainer::from_bytes(&buf).is_err()); + } + + #[test] + fn fuzz_wrong_container_type( + tx_id: u32, + payload in prop::collection::vec(any::(), 0..20), + ) { + let len = 12 + payload.len(); + for (parser_type, wrong_type) in [(2u16, 1u16), (2, 3), (2, 4), (3, 1), (3, 2), (4, 1)] { + let mut buf = (len as u32).to_le_bytes().to_vec(); + buf.extend_from_slice(&wrong_type.to_le_bytes()); + buf.extend_from_slice(&0x1001u16.to_le_bytes()); + buf.extend_from_slice(&tx_id.to_le_bytes()); + buf.extend_from_slice(&payload); + + match parser_type { + 2 => prop_assert!(DataContainer::from_bytes(&buf).is_err()), + 3 => prop_assert!(ResponseContainer::from_bytes(&buf).is_err()), + 4 => prop_assert!(EventContainer::from_bytes(&buf).is_err()), + _ => {} + } + } + } + } + + // Fuzz tests - verify parsers don't panic on arbitrary input + crate::fuzz_bytes_fn!(fuzz_container_type, container_type, 100); + crate::fuzz_bytes!(fuzz_data_container, DataContainer, 100); + crate::fuzz_bytes!(fuzz_response_container, ResponseContainer, 100); + crate::fuzz_bytes!(fuzz_event_container, EventContainer, 100); +} diff --git a/vendor/mtp-rs/src/ptp/device.rs b/vendor/mtp-rs/src/ptp/device.rs new file mode 100644 index 0000000..b022908 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/device.rs @@ -0,0 +1,268 @@ +//! Low-level PTP device API. + +use crate::ptp::{ + container_type, unpack_u32, CommandContainer, ContainerType, DataContainer, DeviceInfo, + OperationCode, PtpSession, ResponseCode, ResponseContainer, +}; +use crate::transport::{NusbTransport, Transport}; +use crate::Error; +use std::sync::Arc; +use std::time::Duration; + +/// A low-level PTP device connection. +/// +/// Use this for camera support or when you need raw PTP operations. +/// For typical MTP usage with Android devices, prefer `MtpDevice` instead. +pub struct PtpDevice { + transport: Arc, +} + +impl PtpDevice { + /// Open a PTP device at a specific USB location (port). + pub async fn open_by_location(location_id: u64) -> Result { + Self::open_by_location_with_timeout(location_id, NusbTransport::DEFAULT_TIMEOUT).await + } + + /// Open by location with custom timeout. + pub async fn open_by_location_with_timeout( + location_id: u64, + timeout: Duration, + ) -> Result { + let devices = NusbTransport::list_mtp_devices()?; + let device_info = devices + .into_iter() + .find(|d| d.location_id == location_id) + .ok_or(Error::NoDevice)?; + Self::open_device(device_info, timeout).await + } + + /// Open a PTP device by its serial number. + pub async fn open_by_serial(serial: &str) -> Result { + Self::open_by_serial_with_timeout(serial, NusbTransport::DEFAULT_TIMEOUT).await + } + + /// Open by serial with custom timeout. + pub async fn open_by_serial_with_timeout( + serial: &str, + timeout: Duration, + ) -> Result { + let devices = NusbTransport::list_mtp_devices()?; + let device_info = devices + .into_iter() + .find(|d| d.serial_number.as_deref() == Some(serial)) + .ok_or(Error::NoDevice)?; + Self::open_device(device_info, timeout).await + } + + /// Open the first available PTP device. + pub async fn open_first() -> Result { + Self::open_first_with_timeout(NusbTransport::DEFAULT_TIMEOUT).await + } + + /// Open the first available device with custom timeout. + pub async fn open_first_with_timeout(timeout: Duration) -> Result { + let devices = NusbTransport::list_mtp_devices()?; + let device_info = devices.into_iter().next().ok_or(Error::NoDevice)?; + Self::open_device(device_info, timeout).await + } + + async fn open_device( + device_info: crate::transport::UsbDeviceInfo, + timeout: Duration, + ) -> Result { + let device = device_info.open().map_err(Error::Usb)?; + let transport = NusbTransport::open_with_timeout(device, timeout).await?; + Ok(Self { + transport: Arc::new(transport) as Arc, + }) + } + + /// Get device info without opening a session. + /// + /// This is the only operation that can be performed without a session. + pub async fn get_device_info(&self) -> Result { + // Build GetDeviceInfo command (transaction ID 0 for session-less) + let cmd = CommandContainer { + code: OperationCode::GetDeviceInfo, + transaction_id: 0, + params: vec![], + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + // Receive data + // A single PTP container may span multiple USB bulk transfers when the + // device sends the 12-byte header in one transfer and the payload in + // follow-up transfers (allowed by the PTP spec, observed on Garmin + // Forerunner 955). Accumulate transfers until the full container is in + // hand, matching the in-session receive logic. + let mut data_payload = Vec::new(); + loop { + let mut bytes = self.transport.receive_bulk(64 * 1024).await?; + if bytes.is_empty() { + return Err(Error::invalid_data("Empty response")); + } + + let ct = container_type(&bytes)?; + match ct { + ContainerType::Data => { + if bytes.len() >= 4 { + let total_length = unpack_u32(&bytes[0..4])? as usize; + while bytes.len() < total_length { + let more = self.transport.receive_bulk(64 * 1024).await?; + if more.is_empty() { + return Err(Error::invalid_data( + "Incomplete data container: device stopped sending", + )); + } + bytes.extend_from_slice(&more); + } + } + let container = DataContainer::from_bytes(&bytes)?; + data_payload.extend_from_slice(&container.payload); + } + ContainerType::Response => { + let response = ResponseContainer::from_bytes(&bytes)?; + if response.code != ResponseCode::Ok { + return Err(Error::Protocol { + code: response.code, + operation: OperationCode::GetDeviceInfo, + }); + } + break; + } + _ => { + return Err(Error::invalid_data(format!( + "Unexpected container type: {:?}", + ct + ))); + } + } + } + + DeviceInfo::from_bytes(&data_payload) + } + + /// Open a PTP session. + /// + /// Most operations require a session to be open first. + pub async fn open_session(&self) -> Result { + self.open_session_with_id(1).await + } + + /// Open a session with a specific session ID. + pub async fn open_session_with_id(&self, session_id: u32) -> Result { + PtpSession::open(Arc::clone(&self.transport), session_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::pack::{pack_u16, pack_u16_array, pack_u32}; + use crate::transport::mock::MockTransport; + + #[tokio::test] + #[ignore] // Requires real device + async fn test_open_first() { + let device = PtpDevice::open_first().await.unwrap(); + let info = device.get_device_info().await.unwrap(); + println!("Model: {}", info.model); + } + + #[tokio::test] + #[ignore] // Requires real device + async fn test_open_session() { + let device = PtpDevice::open_first().await.unwrap(); + let session = device.open_session().await.unwrap(); + + let info = session.get_device_info().await.unwrap(); + println!("Model: {}", info.model); + + session.close().await.unwrap(); + } + + /// Build a minimal valid DeviceInfo payload (no header). + fn minimal_device_info_payload() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&pack_u16(100)); // StandardVersion + buf.extend_from_slice(&pack_u32(0)); // VendorExtensionID + buf.extend_from_slice(&pack_u16(0)); // VendorExtensionVersion + buf.push(0x00); // VendorExtensionDesc (empty string) + buf.extend_from_slice(&pack_u16(0)); // FunctionalMode + buf.extend_from_slice(&pack_u16_array(&[])); // OperationsSupported + buf.extend_from_slice(&pack_u16_array(&[])); // EventsSupported + buf.extend_from_slice(&pack_u16_array(&[])); // DevicePropertiesSupported + buf.extend_from_slice(&pack_u16_array(&[])); // CaptureFormats + buf.extend_from_slice(&pack_u16_array(&[])); // PlaybackFormats + buf.push(0x00); // Manufacturer (empty) + buf.push(0x00); // Model (empty) + buf.push(0x00); // DeviceVersion (empty) + buf.push(0x00); // SerialNumber (empty) + buf + } + + /// Build a 12-byte data container header for GetDeviceInfo. + fn data_container_header(total_length: u32) -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(total_length)); + buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + buf.extend_from_slice(&pack_u16(OperationCode::GetDeviceInfo.into())); + buf.extend_from_slice(&pack_u32(0)); // tx_id 0 (session-less) + buf + } + + /// Build a 12-byte OK response for GetDeviceInfo (tx_id 0). + fn ok_response_session_less() -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(12)); + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); + buf.extend_from_slice(&pack_u32(0)); + buf + } + + #[tokio::test] + async fn get_device_info_handles_split_header_and_data() { + // Regression test: some devices (Garmin Forerunner 955, observed) send + // the 12-byte container header in one bulk transfer and the payload in + // a follow-up transfer. The session-less GetDeviceInfo path must + // accumulate transfers until the full container is received. + let mock = Arc::new(MockTransport::new()); + + let payload = minimal_device_info_payload(); + let total_length = 12 + payload.len() as u32; + + // Split: header in one transfer, payload in another. + mock.queue_response(data_container_header(total_length)); + mock.queue_response(payload); + mock.queue_response(ok_response_session_less()); + + let device = PtpDevice { + transport: mock as Arc, + }; + + let info = device.get_device_info().await.unwrap(); + assert_eq!(info.standard_version, 100); + } + + #[tokio::test] + async fn get_device_info_handles_combined_header_and_data() { + // Happy path: device sends header and payload as a single bulk transfer. + let mock = Arc::new(MockTransport::new()); + + let payload = minimal_device_info_payload(); + let total_length = 12 + payload.len() as u32; + + let mut combined = data_container_header(total_length); + combined.extend_from_slice(&payload); + mock.queue_response(combined); + mock.queue_response(ok_response_session_less()); + + let device = PtpDevice { + transport: mock as Arc, + }; + + let info = device.get_device_info().await.unwrap(); + assert_eq!(info.standard_version, 100); + } +} diff --git a/vendor/mtp-rs/src/ptp/mod.rs b/vendor/mtp-rs/src/ptp/mod.rs new file mode 100644 index 0000000..3f23348 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/mod.rs @@ -0,0 +1,155 @@ +//! Low-level PTP (Picture Transfer Protocol) implementation. +//! +//! This module provides direct access to the PTP/MTP protocol layer. Use this module when: +//! +//! - Working with digital cameras that use PTP +//! - You need fine-grained control over protocol operations +//! - Implementing custom MTP extensions or vendor operations +//! - You need access to raw response codes for error handling +//! - Building your own high-level abstractions +//! +//! ## When to use `mtp` instead +//! +//! Most users working with Android devices should prefer the high-level [`crate::mtp`] module, +//! which provides a simpler API for common operations like listing files, uploading, and +//! downloading. +//! +//! ## Module structure +//! +//! - `codes`: Operation, response, event, and format code enums +//! - `pack`: Binary serialization/deserialization primitives +//! - `container`: USB container format for PTP messages +//! - `types`: DeviceInfo, StorageInfo, ObjectInfo structures +//! - `session`: PTP session management +//! - `device`: PtpDevice public API +//! +//! ## Example +//! +//! ```rust,no_run +//! use mtp_rs::ptp::PtpDevice; +//! +//! # async fn example() -> Result<(), mtp_rs::Error> { +//! // Open device and start a session +//! let device = PtpDevice::open_first().await?; +//! let session = device.open_session().await?; +//! +//! // Get device info +//! let info = session.get_device_info().await?; +//! println!("Model: {}", info.model); +//! +//! // List storage IDs +//! let storage_ids = session.get_storage_ids().await?; +//! # Ok(()) +//! # } +//! ``` + +mod codes; +mod container; +mod device; +mod pack; +mod session; +#[cfg(test)] +mod test_utils; +mod types; + +pub use codes::{ + DevicePropertyCode, EventCode, ObjectFormatCode, ObjectPropertyCode, OperationCode, + PropertyDataType, ResponseCode, +}; +pub use container::{ + container_type, CommandContainer, ContainerType, DataContainer, EventContainer, + ResponseContainer, +}; +pub use device::PtpDevice; +pub use pack::{ + pack_datetime, pack_i16, pack_i32, pack_i64, pack_i8, pack_string, pack_u16, pack_u16_array, + pack_u32, pack_u32_array, pack_u64, pack_u8, unpack_datetime, unpack_i16, unpack_i32, + unpack_i64, unpack_i8, unpack_string, unpack_u16, unpack_u16_array, unpack_u32, + unpack_u32_array, unpack_u64, unpack_u8, DateTime, +}; +pub use session::{receive_stream_to_stream, PtpSession, ReceiveStream}; +pub use types::{ + AccessCapability, AssociationType, DeviceInfo, DevicePropDesc, FilesystemType, ObjectInfo, + PropertyFormType, PropertyRange, PropertyValue, ProtectionStatus, StorageInfo, StorageType, +}; + +/// 32-bit object handle assigned by the device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ObjectHandle(pub u32); + +impl ObjectHandle { + /// Root folder (parent = root means object is in storage root). + pub const ROOT: Self = ObjectHandle(0x00000000); + /// All objects (used in GetObjectHandles to list recursively). + pub const ALL: Self = ObjectHandle(0xFFFFFFFF); +} + +/// 32-bit storage identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct StorageId(pub u32); + +impl StorageId { + /// All storages (used in GetObjectHandles to search all). + pub const ALL: Self = StorageId(0xFFFFFFFF); +} + +/// 32-bit session identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct SessionId(pub u32); + +/// 32-bit transaction identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct TransactionId(pub u32); + +impl TransactionId { + /// The first valid transaction ID in a session. + pub const FIRST: Self = TransactionId(0x00000001); + + /// Invalid transaction ID (must never be used). + pub const INVALID: Self = TransactionId(0xFFFFFFFF); + + /// Transaction ID for session-less operations (e.g., GetDeviceInfo before OpenSession). + pub const SESSION_LESS: Self = TransactionId(0x00000000); + + /// Get the next transaction ID, wrapping correctly. + /// + /// Wraps from 0xFFFFFFFE to 0x00000001 (skipping both 0x00000000 and 0xFFFFFFFF). + #[must_use] + pub fn next(self) -> Self { + let next = self.0.wrapping_add(1); + if next == 0 || next == 0xFFFFFFFF { + TransactionId(0x00000001) + } else { + TransactionId(next) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transaction_id_next() { + assert_eq!(TransactionId(1).next(), TransactionId(2)); + assert_eq!(TransactionId(100).next(), TransactionId(101)); + } + + #[test] + fn transaction_id_wrapping() { + // Should wrap from 0xFFFFFFFE to 0x00000001, skipping 0xFFFFFFFF and 0x00000000 + assert_eq!(TransactionId(0xFFFFFFFE).next(), TransactionId(1)); + assert_eq!(TransactionId(0xFFFFFFFD).next(), TransactionId(0xFFFFFFFE)); + } + + #[test] + fn object_handle_constants() { + assert_eq!(ObjectHandle::ROOT.0, 0); + assert_eq!(ObjectHandle::ALL.0, 0xFFFFFFFF); + } + + #[test] + fn storage_id_constants() { + assert_eq!(StorageId::ALL.0, 0xFFFFFFFF); + } +} diff --git a/vendor/mtp-rs/src/ptp/pack/datetime.rs b/vendor/mtp-rs/src/ptp/pack/datetime.rs new file mode 100644 index 0000000..4c8bd63 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/pack/datetime.rs @@ -0,0 +1,549 @@ +//! DateTime struct and serialization for MTP/PTP. + +use super::{pack_string, unpack_string}; + +/// Date and time structure for MTP/PTP. +/// +/// Format: "YYYYMMDDThhmmss" (ISO 8601 subset) +/// +/// # Validation +/// +/// DateTime values must satisfy these constraints: +/// - Year: 0-9999 (4-digit representation) +/// - Month: 1-12 +/// - Day: 1-31 +/// - Hour: 0-23 +/// - Minute: 0-59 +/// - Second: 0-59 +/// +/// Use [`DateTime::new()`] to create validated instances, or [`DateTime::is_valid()`] +/// to check existing instances. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DateTime { + /// Year (0-9999) + pub year: u16, + /// Month (1-12) + pub month: u8, + /// Day (1-31) + pub day: u8, + /// Hour (0-23) + pub hour: u8, + /// Minute (0-59) + pub minute: u8, + /// Second (0-59) + pub second: u8, +} + +impl DateTime { + /// Create a new DateTime with validation. + /// + /// Returns `None` if any value is out of range. + /// + /// # Example + /// + /// ``` + /// use mtp_rs::ptp::DateTime; + /// + /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); + /// assert_eq!(dt.year, 2024); + /// + /// // Invalid values return None + /// assert!(DateTime::new(2024, 13, 1, 0, 0, 0).is_none()); // month > 12 + /// assert!(DateTime::new(2024, 1, 1, 0, 60, 0).is_none()); // minute > 59 + /// ``` + #[must_use] + pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option { + let dt = DateTime { + year, + month, + day, + hour, + minute, + second, + }; + if dt.is_valid() { + Some(dt) + } else { + None + } + } + + /// Check if this DateTime has valid values. + /// + /// Returns `true` if all fields are within valid ranges: + /// - Year: 0-9999 + /// - Month: 1-12 + /// - Day: 1-31 + /// - Hour: 0-23 + /// - Minute: 0-59 + /// - Second: 0-59 + /// + /// Note: This does not validate day-of-month against the specific month + /// (e.g., Feb 31 would pass). MTP devices generally accept any 1-31 value. + #[must_use] + pub fn is_valid(&self) -> bool { + self.year <= 9999 + && (1..=12).contains(&self.month) + && (1..=31).contains(&self.day) + && self.hour <= 23 + && self.minute <= 59 + && self.second <= 59 + } + + /// Parse a datetime string in MTP format. + /// + /// Format: "YYYYMMDDThhmmss" with optional timezone suffix (Z or +hhmm/-hhmm). + /// The timezone suffix is parsed but ignored. + /// + /// Returns `None` if the string is malformed or contains invalid values. + #[must_use] + pub fn parse(s: &str) -> Option { + // Minimum length: "YYYYMMDDThhmmss" = 15 characters + if s.len() < 15 { + return None; + } + + // Check for 'T' separator at position 8 + if s.as_bytes().get(8) != Some(&b'T') { + return None; + } + + // Parse components + let year: u16 = s.get(0..4)?.parse().ok()?; + let month: u8 = s.get(4..6)?.parse().ok()?; + let day: u8 = s.get(6..8)?.parse().ok()?; + let hour: u8 = s.get(9..11)?.parse().ok()?; + let minute: u8 = s.get(11..13)?.parse().ok()?; + let second: u8 = s.get(13..15)?.parse().ok()?; + + // Use new() which validates + Self::new(year, month, day, hour, minute, second) + } + + /// Format the datetime as an MTP string. + /// + /// Returns `Some("YYYYMMDDThhmmss")` if the values are valid (exactly 15 characters), + /// or `None` if any value is out of range. + /// + /// # Example + /// + /// ``` + /// use mtp_rs::ptp::DateTime; + /// + /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); + /// assert_eq!(dt.format(), Some("20240315T143022".to_string())); + /// + /// // Invalid DateTime returns None + /// let invalid = DateTime { year: 2024, month: 13, day: 1, hour: 0, minute: 0, second: 0 }; + /// assert_eq!(invalid.format(), None); + /// ``` + #[must_use] + pub fn format(&self) -> Option { + if !self.is_valid() { + return None; + } + Some(format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}", + self.year, self.month, self.day, self.hour, self.minute, self.second + )) + } +} + +/// Pack a DateTime into MTP string format. +/// +/// Returns an error if the DateTime contains invalid values. +pub fn pack_datetime(dt: &DateTime) -> Result, crate::Error> { + let formatted = dt.format().ok_or_else(|| { + crate::Error::invalid_data(format!( + "invalid DateTime: year={}, month={}, day={}, hour={}, minute={}, second={}", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second + )) + })?; + Ok(pack_string(&formatted)) +} + +/// Unpack a DateTime from a buffer. +/// +/// Returns the datetime (or None for empty string) and the number of bytes consumed. +pub fn unpack_datetime(buf: &[u8]) -> Result<(Option, usize), crate::Error> { + let (s, consumed) = unpack_string(buf)?; + + if s.is_empty() { + return Ok((None, consumed)); + } + + let dt = DateTime::parse(&s) + .ok_or_else(|| crate::Error::invalid_data(format!("invalid datetime format: {}", s)))?; + + Ok((Some(dt), consumed)) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // --- DateTime parsing tests --- + + #[test] + fn datetime_parse_basic() { + let dt = DateTime::parse("20240315T143022").unwrap(); + assert_eq!( + (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second), + (2024, 3, 15, 14, 30, 22) + ); + } + + #[test] + fn datetime_parse_with_timezone_z() { + assert!(DateTime::parse("20240315T143022Z").is_some()); + } + + #[test] + fn datetime_parse_with_timezone_positive() { + assert!(DateTime::parse("20240315T143022+0530").is_some()); + } + + #[test] + fn datetime_parse_with_timezone_negative() { + assert!(DateTime::parse("20240315T143022-0800").is_some()); + } + + #[test] + fn datetime_parse_invalid_too_short() { + for s in ["2024031", ""] { + assert!(DateTime::parse(s).is_none()); + } + } + + #[test] + fn datetime_parse_invalid_no_t_separator() { + for s in ["20240315 143022", "20240315143022"] { + assert!(DateTime::parse(s).is_none()); + } + } + + #[test] + fn datetime_parse_invalid_month() { + for s in ["20240015T143022", "20241315T143022"] { + // month=0, month=13 + assert!(DateTime::parse(s).is_none()); + } + } + + #[test] + fn datetime_parse_invalid_day() { + for s in ["20240100T143022", "20240132T143022"] { + // day=0, day=32 + assert!(DateTime::parse(s).is_none()); + } + } + + #[test] + fn datetime_parse_invalid_hour() { + assert!(DateTime::parse("20240315T243022").is_none()); // hour=24 + } + + #[test] + fn datetime_parse_invalid_minute() { + assert!(DateTime::parse("20240315T146022").is_none()); // minute=60 + } + + #[test] + fn datetime_parse_invalid_second() { + assert!(DateTime::parse("20240315T143060").is_none()); // second=60 + } + + // --- DateTime format tests --- + + #[test] + fn datetime_format() { + assert_eq!( + DateTime::new(2024, 3, 15, 14, 30, 22).unwrap().format(), + Some("20240315T143022".into()) + ); + } + + #[test] + fn datetime_format_with_leading_zeros() { + assert_eq!( + DateTime::new(2024, 1, 5, 9, 5, 3).unwrap().format(), + Some("20240105T090503".into()) + ); + } + + #[test] + fn datetime_roundtrip() { + let original = DateTime::new(2024, 12, 31, 23, 59, 59).unwrap(); + assert_eq!( + DateTime::parse(&original.format().unwrap()).unwrap(), + original + ); + } + + #[test] + fn datetime_format_invalid_returns_none() { + let invalid_cases = [ + (2024, 13, 1, 0, 0, 0), // invalid month + (2024, 1, 1, 0, 60, 0), // invalid minute + (10000, 1, 1, 0, 0, 0), // year too large + ]; + for (y, mo, d, h, mi, s) in invalid_cases { + let dt = DateTime { + year: y, + month: mo, + day: d, + hour: h, + minute: mi, + second: s, + }; + assert_eq!(dt.format(), None); + } + } + + #[test] + fn datetime_default() { + let dt = DateTime::default(); + assert_eq!( + (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second), + (0, 0, 0, 0, 0, 0) + ); + } + + // --- Pack/unpack tests --- + + #[test] + fn pack_datetime_basic() { + let packed = pack_datetime(&DateTime::new(2024, 3, 15, 14, 30, 22).unwrap()).unwrap(); + assert_eq!(packed[0], 16); // 15 chars + null terminator + } + + #[test] + fn pack_datetime_invalid_returns_error() { + let invalid = DateTime { + year: 2024, + month: 13, + day: 1, + hour: 0, + minute: 0, + second: 0, + }; + assert!(pack_datetime(&invalid).is_err()); + } + + #[test] + fn unpack_datetime_basic() { + let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); + let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap(); + assert_eq!(unpacked, Some(dt)); + } + + #[test] + fn unpack_datetime_empty_string() { + let (dt, consumed) = unpack_datetime(&[0x00]).unwrap(); + assert_eq!((dt, consumed), (None, 1)); + } + + #[test] + fn unpack_datetime_invalid_format() { + assert!(unpack_datetime(&pack_string("not a date")).is_err()); + } + + #[test] + fn datetime_pack_unpack_roundtrip() { + for dt in [ + DateTime::new(2024, 1, 1, 0, 0, 0).unwrap(), + DateTime::new(2024, 12, 31, 23, 59, 59).unwrap(), + DateTime::new(1999, 6, 15, 12, 30, 45).unwrap(), + ] { + let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap(); + assert_eq!(unpacked, Some(dt)); + } + } + + // --- Boundary tests --- + + #[test] + fn datetime_boundary_day_31() { + let dt = DateTime { + year: 2024, + month: 1, + day: 31, + hour: 0, + minute: 0, + second: 0, + }; + let parsed = DateTime::parse(&dt.format().unwrap()).unwrap(); + assert_eq!(parsed.day, 31); + } + + #[test] + fn datetime_boundary_year_0() { + let dt = DateTime { + year: 0, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + }; + if let Some(p) = DateTime::parse(&dt.format().unwrap()) { + assert_eq!(p.year, 0); + } + } + + #[test] + fn datetime_boundary_year_9999() { + let dt = DateTime { + year: 9999, + month: 12, + day: 31, + hour: 23, + minute: 59, + second: 59, + }; + assert_eq!(DateTime::parse(&dt.format().unwrap()).unwrap().year, 9999); + } + + #[test] + fn datetime_boundary_year_10000() { + let dt = DateTime { + year: 10000, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + }; + assert!(dt.format().is_none()); + assert!(pack_datetime(&dt).is_err()); + } + + // --- Property-based tests --- + + fn valid_datetime() -> impl Strategy { + ( + 1000u16..9999u16, + 1u8..=12u8, + 1u8..=28u8, + 0u8..=23u8, + 0u8..=59u8, + 0u8..=59u8, + ) + .prop_map(|(year, month, day, hour, minute, second)| DateTime { + year, + month, + day, + hour, + minute, + second, + }) + } + + proptest! { + #[test] + fn prop_datetime_format_parse_roundtrip(dt in valid_datetime()) { + let formatted = dt.format().unwrap(); + prop_assert_eq!(DateTime::parse(&formatted).unwrap(), dt); + } + + #[test] + fn prop_datetime_pack_unpack_roundtrip(dt in valid_datetime()) { + let packed = pack_datetime(&dt).unwrap(); + let (unpacked, consumed) = unpack_datetime(&packed).unwrap(); + prop_assert_eq!(unpacked, Some(dt)); + prop_assert_eq!(consumed, packed.len()); + } + + #[test] + fn prop_datetime_format_length(dt in valid_datetime()) { + prop_assert_eq!(dt.format().unwrap().len(), 15); + } + + #[test] + fn fuzz_datetime_invalid_month( + year in 1900u16..2100u16, + month in prop::sample::select(vec![0u8, 13, 14, 99, 255]), + day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_invalid_day( + year in 1900u16..2100u16, month in 1u8..=12u8, + day in prop::sample::select(vec![0u8, 32, 33, 99, 255]), + hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_invalid_hour( + year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, + hour in prop::sample::select(vec![24u8, 25, 99, 255]), + minute in 0u8..=59u8, second in 0u8..=59u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_invalid_minute( + year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, + minute in prop::sample::select(vec![60u8, 61, 99]), + second in 0u8..=59u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_minute_overflow( + year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, + minute in 100u8..=255u8, second in 0u8..=59u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_invalid_second( + year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, + second in prop::sample::select(vec![60u8, 61, 99]), + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_second_overflow( + year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, + second in 100u8..=255u8, + ) { + let dt = DateTime { year, month, day, hour, minute, second }; + prop_assert!(dt.format().is_none()); + prop_assert!(pack_datetime(&dt).is_err()); + } + + #[test] + fn fuzz_datetime_parse_garbage(s in ".*") { + let _ = DateTime::parse(&s); + } + + #[test] + fn fuzz_datetime_parse_malformed(prefix in "[0-9]{0,20}", suffix in "[^T]*") { + let _ = DateTime::parse(&format!("{}{}", prefix, suffix)); + } + } + + crate::fuzz_bytes_fn!(fuzz_unpack_datetime, unpack_datetime, 50); +} diff --git a/vendor/mtp-rs/src/ptp/pack/mod.rs b/vendor/mtp-rs/src/ptp/pack/mod.rs new file mode 100644 index 0000000..852f72f --- /dev/null +++ b/vendor/mtp-rs/src/ptp/pack/mod.rs @@ -0,0 +1,534 @@ +//! Binary serialization/deserialization primitives for MTP/PTP. +//! +//! All multi-byte values are little-endian. + +mod datetime; + +pub use datetime::{pack_datetime, unpack_datetime, DateTime}; + +// --- Primitive packing functions --- + +/// Pack a u8 value into a 1-byte array. +#[inline] +pub fn pack_u8(val: u8) -> [u8; 1] { + [val] +} + +/// Pack a u16 value into a 2-byte array (little-endian). +#[inline] +pub fn pack_u16(val: u16) -> [u8; 2] { + val.to_le_bytes() +} + +/// Pack a u32 value into a 4-byte array (little-endian). +#[inline] +pub fn pack_u32(val: u32) -> [u8; 4] { + val.to_le_bytes() +} + +/// Pack a u64 value into an 8-byte array (little-endian). +#[inline] +pub fn pack_u64(val: u64) -> [u8; 8] { + val.to_le_bytes() +} + +/// Pack a signed 8-bit integer. +#[inline] +pub fn pack_i8(val: i8) -> [u8; 1] { + [val as u8] +} + +/// Pack a signed 16-bit integer (little-endian). +#[inline] +pub fn pack_i16(val: i16) -> [u8; 2] { + val.to_le_bytes() +} + +/// Pack a signed 32-bit integer (little-endian). +#[inline] +pub fn pack_i32(val: i32) -> [u8; 4] { + val.to_le_bytes() +} + +/// Pack a signed 64-bit integer (little-endian). +#[inline] +pub fn pack_i64(val: i64) -> [u8; 8] { + val.to_le_bytes() +} + +// --- Primitive unpacking functions --- + +/// Unpack a u8 value from a buffer. +pub fn unpack_u8(buf: &[u8]) -> Result { + if buf.is_empty() { + return Err(crate::Error::invalid_data( + "insufficient bytes for u8: need 1, have 0", + )); + } + Ok(buf[0]) +} + +/// Unpack a u16 value from a buffer (little-endian). +pub fn unpack_u16(buf: &[u8]) -> Result { + if buf.len() < 2 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for u16: need 2, have {}", + buf.len() + ))); + } + Ok(u16::from_le_bytes([buf[0], buf[1]])) +} + +/// Unpack a u32 value from a buffer (little-endian). +pub fn unpack_u32(buf: &[u8]) -> Result { + if buf.len() < 4 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for u32: need 4, have {}", + buf.len() + ))); + } + Ok(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) +} + +/// Unpack a u64 value from a buffer (little-endian). +pub fn unpack_u64(buf: &[u8]) -> Result { + if buf.len() < 8 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for u64: need 8, have {}", + buf.len() + ))); + } + Ok(u64::from_le_bytes([ + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + ])) +} + +/// Unpack a signed 8-bit integer from a buffer. +pub fn unpack_i8(buf: &[u8]) -> Result { + if buf.is_empty() { + return Err(crate::Error::invalid_data( + "insufficient bytes for i8: need 1, have 0", + )); + } + Ok(buf[0] as i8) +} + +/// Unpack a signed 16-bit integer from a buffer (little-endian). +pub fn unpack_i16(buf: &[u8]) -> Result { + if buf.len() < 2 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for i16: need 2, have {}", + buf.len() + ))); + } + Ok(i16::from_le_bytes([buf[0], buf[1]])) +} + +/// Unpack a signed 32-bit integer from a buffer (little-endian). +pub fn unpack_i32(buf: &[u8]) -> Result { + if buf.len() < 4 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for i32: need 4, have {}", + buf.len() + ))); + } + Ok(i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) +} + +/// Unpack a signed 64-bit integer from a buffer (little-endian). +pub fn unpack_i64(buf: &[u8]) -> Result { + if buf.len() < 8 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for i64: need 8, have {}", + buf.len() + ))); + } + Ok(i64::from_le_bytes([ + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + ])) +} + +// --- String encoding/decoding --- + +/// Pack a string into MTP format (UTF-16LE with length prefix). +/// +/// MTP strings are encoded as: +/// 1. 1 byte: Number of characters (including null terminator) +/// 2. N * 2 bytes: UTF-16LE encoded characters +/// 3. 2 bytes: Null terminator (0x0000) +/// +/// Empty string: Single byte 0x00 +pub fn pack_string(s: &str) -> Vec { + if s.is_empty() { + return vec![0x00]; + } + + // Encode to UTF-16 + let utf16: Vec = s.encode_utf16().collect(); + + // Length includes the null terminator + let len = utf16.len() + 1; + + // Allocate result: 1 byte length + (len * 2) bytes for UTF-16 data + let mut result = Vec::with_capacity(1 + len * 2); + + // Length byte (number of characters including null terminator) + result.push(len as u8); + + // UTF-16LE encoded characters + for code_unit in &utf16 { + result.extend_from_slice(&code_unit.to_le_bytes()); + } + + // Null terminator + result.extend_from_slice(&[0x00, 0x00]); + + result +} + +/// Unpack an MTP string from a buffer. +/// +/// Returns the decoded string and the number of bytes consumed. +pub fn unpack_string(buf: &[u8]) -> Result<(String, usize), crate::Error> { + if buf.is_empty() { + return Err(crate::Error::invalid_data( + "insufficient bytes for string length", + )); + } + + let len = buf[0] as usize; + + // Empty string + if len == 0 { + return Ok((String::new(), 1)); + } + + // Calculate required bytes: 1 (length) + len * 2 (UTF-16 code units) + let required = 1 + len * 2; + if buf.len() < required { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for string: need {}, have {}", + required, + buf.len() + ))); + } + + // Decode UTF-16LE code units + let mut code_units = Vec::with_capacity(len); + for i in 0..len { + let offset = 1 + i * 2; + let code_unit = u16::from_le_bytes([buf[offset], buf[offset + 1]]); + code_units.push(code_unit); + } + + // Remove null terminator if present + if code_units.last() == Some(&0) { + code_units.pop(); + } + + // Decode UTF-16 to String + let s = String::from_utf16(&code_units) + .map_err(|_| crate::Error::invalid_data("invalid UTF-16 encoding"))?; + + Ok((s, required)) +} + +// --- Array encoding/decoding --- + +/// Pack a u16 array into MTP format. +/// +/// Arrays are encoded as: +/// 1. 4 bytes: Element count (u32, little-endian) +/// 2. N * 2 bytes: Elements (u16, little-endian each) +pub fn pack_u16_array(arr: &[u16]) -> Vec { + let mut result = Vec::with_capacity(4 + arr.len() * 2); + + // Element count + result.extend_from_slice(&pack_u32(arr.len() as u32)); + + // Elements + for &val in arr { + result.extend_from_slice(&pack_u16(val)); + } + + result +} + +/// Pack a u32 array into MTP format. +/// +/// Arrays are encoded as: +/// 1. 4 bytes: Element count (u32, little-endian) +/// 2. N * 4 bytes: Elements (u32, little-endian each) +pub fn pack_u32_array(arr: &[u32]) -> Vec { + let mut result = Vec::with_capacity(4 + arr.len() * 4); + + // Element count + result.extend_from_slice(&pack_u32(arr.len() as u32)); + + // Elements + for &val in arr { + result.extend_from_slice(&pack_u32(val)); + } + + result +} + +/// Unpack a u16 array from a buffer. +/// +/// Returns the array and the number of bytes consumed. +pub fn unpack_u16_array(buf: &[u8]) -> Result<(Vec, usize), crate::Error> { + if buf.len() < 4 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for array count: need 4, have {}", + buf.len() + ))); + } + + let count = unpack_u32(buf)? as usize; + let required = 4 + count * 2; + + if buf.len() < required { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for u16 array: need {}, have {}", + required, + buf.len() + ))); + } + + let mut result = Vec::with_capacity(count); + for i in 0..count { + let offset = 4 + i * 2; + result.push(unpack_u16(&buf[offset..])?); + } + + Ok((result, required)) +} + +/// Unpack a u32 array from a buffer. +/// +/// Returns the array and the number of bytes consumed. +pub fn unpack_u32_array(buf: &[u8]) -> Result<(Vec, usize), crate::Error> { + if buf.len() < 4 { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for array count: need 4, have {}", + buf.len() + ))); + } + + let count = unpack_u32(buf)? as usize; + let required = 4 + count * 4; + + if buf.len() < required { + return Err(crate::Error::invalid_data(format!( + "insufficient bytes for u32 array: need {}, have {}", + required, + buf.len() + ))); + } + + let mut result = Vec::with_capacity(count); + for i in 0..count { + let offset = 4 + i * 4; + result.push(unpack_u32(&buf[offset..])?); + } + + Ok((result, required)) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // --- Primitive pack/unpack tests --- + + #[test] + fn pack_primitives_little_endian() { + // Verify little-endian byte order + assert_eq!(pack_u16(0x1234), [0x34, 0x12]); + assert_eq!(pack_u32(0x12345678), [0x78, 0x56, 0x34, 0x12]); + assert_eq!( + pack_u64(0x0102030405060708), + [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01] + ); + assert_eq!(pack_i16(-1), [0xFF, 0xFF]); + assert_eq!(pack_i32(-1), [0xFF, 0xFF, 0xFF, 0xFF]); + } + + #[test] + fn unpack_insufficient_bytes() { + assert!(unpack_u8(&[]).is_err()); + assert!(unpack_u16(&[0x00]).is_err()); + assert!(unpack_u32(&[0x00, 0x00, 0x00]).is_err()); + assert!(unpack_u64(&[0x00; 7]).is_err()); + assert!(unpack_i8(&[]).is_err()); + assert!(unpack_i16(&[0x00]).is_err()); + assert!(unpack_i32(&[0x00, 0x00, 0x00]).is_err()); + assert!(unpack_i64(&[0x00; 7]).is_err()); + } + + // --- String tests --- + + #[test] + fn pack_string_formats() { + assert_eq!(pack_string(""), vec![0x00]); + assert_eq!( + pack_string("Hi"), + vec![0x03, 0x48, 0x00, 0x69, 0x00, 0x00, 0x00] // len=3, 'H', 'i', null + ); + } + + #[test] + fn pack_string_emoji_surrogate_pair() { + let packed = pack_string("\u{1F600}"); + assert_eq!(packed[0], 3); // surrogate pair (2 units) + null + assert_eq!(&packed[1..5], &[0x3D, 0xD8, 0x00, 0xDE]); // 0xD83D, 0xDE00 + } + + #[test] + fn unpack_string_errors() { + assert!(unpack_string(&[]).is_err()); + assert!(unpack_string(&[0x03, 0x41, 0x00]).is_err()); // truncated + // Invalid surrogate pair + assert!(unpack_string(&[0x02, 0x00, 0xD8, 0x00, 0x00]).is_err()); + } + + #[test] + fn roundtrip_strings() { + for s in ["", "Hello", "\u{3053}\u{3093}", "\u{1F600}"] { + let (unpacked, _) = unpack_string(&pack_string(s)).unwrap(); + assert_eq!(unpacked, s); + } + } + + // --- Array tests --- + + #[test] + fn pack_arrays() { + assert_eq!(pack_u16_array(&[]), vec![0x00, 0x00, 0x00, 0x00]); + assert_eq!( + pack_u16_array(&[1, 2]), + vec![0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00] + ); + assert_eq!( + pack_u32_array(&[1]), + vec![0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00] + ); + } + + #[test] + fn unpack_array_errors() { + assert!(unpack_u16_array(&[]).is_err()); + assert!(unpack_u32_array(&[0x00, 0x00, 0x00]).is_err()); + // Count says 2, only 1 element + assert!(unpack_u16_array(&[0x02, 0x00, 0x00, 0x00, 0x01, 0x00]).is_err()); + assert!(unpack_u32_array(&[0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]).is_err()); + } + + #[test] + fn roundtrip_arrays() { + for arr in [vec![], vec![1u16, 2, 3], vec![0xFFFF]] { + let (unpacked, _) = unpack_u16_array(&pack_u16_array(&arr)).unwrap(); + assert_eq!(unpacked, arr); + } + for arr in [vec![], vec![1u32, 2, 3], vec![0xFFFFFFFF]] { + let (unpacked, _) = unpack_u32_array(&pack_u32_array(&arr)).unwrap(); + assert_eq!(unpacked, arr); + } + } + + // --- Property-based tests --- + + fn valid_utf16_string() -> impl Strategy { + prop::collection::vec( + prop::char::range('\u{0000}', '\u{D7FF}') + .prop_union(prop::char::range('\u{E000}', '\u{FFFF}')), + 0..100, + ) + .prop_map(|chars| chars.into_iter().collect()) + } + + proptest! { + #[test] + fn prop_roundtrip_primitives( + u8_val: u8, u16_val: u16, u32_val: u32, u64_val: u64, + i8_val: i8, i16_val: i16, i32_val: i32, i64_val: i64 + ) { + prop_assert_eq!(unpack_u8(&pack_u8(u8_val)).unwrap(), u8_val); + prop_assert_eq!(unpack_u16(&pack_u16(u16_val)).unwrap(), u16_val); + prop_assert_eq!(unpack_u32(&pack_u32(u32_val)).unwrap(), u32_val); + prop_assert_eq!(unpack_u64(&pack_u64(u64_val)).unwrap(), u64_val); + prop_assert_eq!(unpack_i8(&pack_i8(i8_val)).unwrap(), i8_val); + prop_assert_eq!(unpack_i16(&pack_i16(i16_val)).unwrap(), i16_val); + prop_assert_eq!(unpack_i32(&pack_i32(i32_val)).unwrap(), i32_val); + prop_assert_eq!(unpack_i64(&pack_i64(i64_val)).unwrap(), i64_val); + } + + #[test] + fn prop_roundtrip_string(s in valid_utf16_string()) { + let s: String = s.chars().take(254).collect(); + let packed = pack_string(&s); + let (unpacked, consumed) = unpack_string(&packed).unwrap(); + prop_assert_eq!(&unpacked, &s); + prop_assert_eq!(consumed, packed.len()); + } + + #[test] + fn prop_roundtrip_arrays( + u16_arr in prop::collection::vec(any::(), 0..50), + u32_arr in prop::collection::vec(any::(), 0..50) + ) { + let packed16 = pack_u16_array(&u16_arr); + let (unpacked16, consumed16) = unpack_u16_array(&packed16).unwrap(); + prop_assert_eq!(&unpacked16, &u16_arr); + prop_assert_eq!(consumed16, packed16.len()); + + let packed32 = pack_u32_array(&u32_arr); + let (unpacked32, consumed32) = unpack_u32_array(&packed32).unwrap(); + prop_assert_eq!(&unpacked32, &u32_arr); + prop_assert_eq!(consumed32, packed32.len()); + } + + #[test] + fn prop_unpack_ignores_extra_bytes(val: u32, extra in prop::collection::vec(any::(), 0..10)) { + let mut buf = pack_u32(val).to_vec(); + buf.extend_from_slice(&extra); + prop_assert_eq!(unpack_u32(&buf).unwrap(), val); + } + + #[test] + fn fuzz_truncated_buffers(bytes in prop::collection::vec(any::(), 1..8)) { + if bytes.len() < 2 { prop_assert!(unpack_u16(&bytes).is_err()); } + if bytes.len() < 4 { prop_assert!(unpack_u32(&bytes).is_err()); } + if bytes.len() < 8 { prop_assert!(unpack_u64(&bytes).is_err()); } + if bytes.len() < 2 { prop_assert!(unpack_i16(&bytes).is_err()); } + if bytes.len() < 4 { prop_assert!(unpack_i32(&bytes).is_err()); } + if bytes.len() < 8 { prop_assert!(unpack_i64(&bytes).is_err()); } + } + + #[test] + fn fuzz_array_invalid_count( + claimed_count in 2u32..100u32, + actual_elements in prop::collection::vec(any::(), 0..5) + ) { + let mut buf = pack_u32(claimed_count).to_vec(); + for elem in &actual_elements { + buf.extend_from_slice(&pack_u32(*elem)); + } + if claimed_count as usize > actual_elements.len() { + prop_assert!(unpack_u32_array(&buf).is_err()); + } + } + + #[test] + fn fuzz_large_array_count(count in (u32::MAX - 100)..=u32::MAX) { + prop_assert!(unpack_u32_array(&pack_u32(count)).is_err()); + prop_assert!(unpack_u16_array(&pack_u32(count)).is_err()); + } + } + + // Fuzz tests - verify parsers don't panic on arbitrary input + crate::fuzz_bytes_fn!(fuzz_unpack_string, unpack_string, 100); + crate::fuzz_bytes_fn!(fuzz_unpack_u16_array, unpack_u16_array, 50); + crate::fuzz_bytes_fn!(fuzz_unpack_u32_array, unpack_u32_array, 50); +} diff --git a/vendor/mtp-rs/src/ptp/session/mod.rs b/vendor/mtp-rs/src/ptp/session/mod.rs new file mode 100644 index 0000000..3223669 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/session/mod.rs @@ -0,0 +1,889 @@ +//! PTP session management. +//! +//! This module provides session-level operations for MTP/PTP communication. +//! A session maintains the connection state and serializes concurrent operations. + +mod operations; +mod properties; +mod streaming; + +pub use streaming::{receive_stream_to_stream, ReceiveStream}; + +use crate::ptp::{ + container_type, pack_u16, pack_u32, unpack_u32, CommandContainer, ContainerType, DataContainer, + OperationCode, ResponseCode, ResponseContainer, SessionId, TransactionId, +}; +use crate::transport::Transport; +use crate::Error; +use futures::lock::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; + +/// Container header size in bytes. +pub(crate) const HEADER_SIZE: usize = 12; + +/// A PTP session with a device. +/// +/// PtpSession manages the lifecycle of a PTP/MTP session, including: +/// - Opening and closing sessions +/// - Transaction ID management +/// - Serializing concurrent operations (MTP only allows one operation at a time) +/// - Executing operations and receiving responses +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::ptp::PtpDevice; +/// +/// # async fn example() -> Result<(), mtp_rs::Error> { +/// let device = PtpDevice::open_first().await?; +/// let session = device.open_session().await?; +/// +/// // Get device info +/// let device_info = session.get_device_info().await?; +/// +/// // Get storage IDs +/// let storage_ids = session.get_storage_ids().await?; +/// +/// // Close the session when done +/// session.close().await?; +/// # Ok(()) +/// # } +/// ``` +pub struct PtpSession { + /// The transport layer for USB communication. + pub(crate) transport: Arc, + /// The session ID assigned to this session. + session_id: SessionId, + /// Atomic counter for generating transaction IDs. + transaction_id: AtomicU32, + /// Mutex to serialize operations (MTP only allows one operation at a time). + /// Wrapped in Arc so it can be shared with ReceiveStream. + pub(crate) operation_lock: Arc>, + /// Whether to send data container headers separately from payloads. + /// Some devices require the 12-byte PTP header and data payload to arrive + /// as separate USB bulk transfers. + split_header_data: AtomicBool, +} + +impl PtpSession { + /// Create a new session (internal, use open() to start session). + fn new(transport: Arc, session_id: SessionId) -> Self { + Self { + transport, + session_id, + transaction_id: AtomicU32::new(TransactionId::FIRST.0), + operation_lock: Arc::new(Mutex::new(())), + split_header_data: AtomicBool::new(false), + } + } + + /// Enable or disable split header/data mode for sending data containers. + /// + /// When enabled, the 12-byte PTP container header and payload are sent as + /// separate USB bulk transfers in [`execute_with_send`](Self::execute_with_send). + /// Required by some devices that don't handle a combined header+data + /// bulk transfer. + pub fn set_split_header_data(&self, split: bool) { + self.split_header_data.store(split, Ordering::Relaxed); + } + + /// Whether split header/data mode is currently enabled. + #[must_use] + pub fn is_split_header_data(&self) -> bool { + self.split_header_data.load(Ordering::Relaxed) + } + + /// Open a new session with the device. + /// + /// This sends an OpenSession command to the device and establishes a session + /// with the given session ID. + /// + /// # Arguments + /// + /// * `transport` - The transport layer for USB communication + /// * `session_id` - The session ID to use (typically 1) + /// + /// # Errors + /// + /// Returns an error if the device rejects the session or communication fails. + pub async fn open(transport: Arc, session_id: u32) -> Result { + Self::open_with_reuse(transport, session_id, false).await + } + + /// Open a session, reusing an existing device-side session when one is reported. + pub async fn open_reusing_existing( + transport: Arc, + session_id: u32, + ) -> Result { + Self::open_with_reuse(transport, session_id, true).await + } + + async fn open_with_reuse( + transport: Arc, + session_id: u32, + reuse_existing: bool, + ) -> Result { + let session = Self::new(transport, SessionId(session_id)); + + // PTP spec: OpenSession is a session-less operation, so use tx_id=0. + // Some devices (e.g. Amazon Kindle) enforce this strictly and reject + // OpenSession with tx_id != 0 as InvalidParameter. + let response = Self::send_open_session(&session.transport, session_id).await?; + + if response.code == ResponseCode::Ok { + return Ok(session); + } + + if response.code == ResponseCode::SessionAlreadyOpen { + if reuse_existing { + return Ok(session); + } + + // Session already exists with potentially mismatched transaction ID. + // Close the existing session (ignore errors) and open a fresh one. + let _ = session.execute(OperationCode::CloseSession, &[]).await; + + // Create a new session instance with reset transaction ID counter + let fresh_session = Self::new(Arc::clone(&session.transport), SessionId(session_id)); + + let retry_response = + Self::send_open_session(&fresh_session.transport, session_id).await?; + + if retry_response.code != ResponseCode::Ok { + return Err(Error::Protocol { + code: retry_response.code, + operation: OperationCode::OpenSession, + }); + } + + return Ok(fresh_session); + } + + Err(Error::Protocol { + code: response.code, + operation: OperationCode::OpenSession, + }) + } + + /// Send OpenSession with transaction_id=0 (SESSION_LESS) per the PTP spec. + async fn send_open_session( + transport: &Arc, + session_id: u32, + ) -> Result { + let cmd = CommandContainer { + code: OperationCode::OpenSession, + transaction_id: TransactionId::SESSION_LESS.0, + params: vec![session_id], + }; + transport.send_bulk(&cmd.to_bytes()).await?; + + let response_bytes = transport.receive_bulk(512).await?; + ResponseContainer::from_bytes(&response_bytes) + } + + /// Get the session ID. + #[must_use] + pub fn session_id(&self) -> SessionId { + self.session_id + } + + /// Close the session. + /// + /// This sends a CloseSession command to the device. Errors during close + /// are ignored since the session is being terminated anyway. + pub async fn close(self) -> Result<(), Error> { + let _ = self.execute(OperationCode::CloseSession, &[]).await; + Ok(()) + } + + /// Get the next transaction ID. + /// + /// Transaction IDs start at 1 and wrap correctly, skipping 0 and 0xFFFFFFFF. + pub(crate) fn next_transaction_id(&self) -> u32 { + loop { + let current = self.transaction_id.load(Ordering::SeqCst); + let next = TransactionId(current).next().0; + if self + .transaction_id + .compare_exchange(current, next, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return current; + } + } + } + + // ========================================================================= + // Core operation execution + // ========================================================================= + + /// Execute a PTP operation without a data phase. + /// + /// Exposed for vendor-specific or otherwise non-standard PTP operations + /// that aren't covered by the high-level API. Access via + /// [`MtpDevice::session()`](crate::mtp::MtpDevice::session). + pub async fn execute( + &self, + operation: OperationCode, + params: &[u32], + ) -> Result { + let _guard = self.operation_lock.lock().await; + + let tx_id = self.next_transaction_id(); + + // Build and send command + let cmd = CommandContainer { + code: operation, + transaction_id: tx_id, + params: params.to_vec(), + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + // Receive response + let response_bytes = self.transport.receive_bulk(512).await?; + let response = ResponseContainer::from_bytes(&response_bytes)?; + + // Verify transaction ID matches + if response.transaction_id != tx_id { + return Err(Error::invalid_data(format!( + "Transaction ID mismatch: expected {}, got {}", + tx_id, response.transaction_id + ))); + } + + Ok(response) + } + + /// Execute a PTP operation with a data receive phase. + /// + /// Returns the response container along with the received data payload. + /// Useful for vendor-specific operations that return data. + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::mtp::MtpDevice; + /// use mtp_rs::ptp::{OperationCode, ResponseCode}; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// let device = MtpDevice::open_first().await?; + /// + /// // Execute a vendor-specific operation (0x9501) + /// let (response, data) = device.session() + /// .execute_with_receive(OperationCode::Unknown(0x9501), &[]) + /// .await?; + /// + /// if response.code == ResponseCode::Ok { + /// println!("received {} bytes", data.len()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn execute_with_receive( + &self, + operation: OperationCode, + params: &[u32], + ) -> Result<(ResponseContainer, Vec), Error> { + let _guard = self.operation_lock.lock().await; + + let tx_id = self.next_transaction_id(); + + // Send command + let cmd = CommandContainer { + code: operation, + transaction_id: tx_id, + params: params.to_vec(), + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + // Receive data container(s) + // MTP sends data in one or more containers, then response. + // A single data container may span multiple USB transfers if larger than 64KB. + let mut data = Vec::new(); + + loop { + let mut bytes = self.transport.receive_bulk(64 * 1024).await?; + if bytes.is_empty() { + return Err(Error::invalid_data("Empty response")); + } + + let ct = container_type(&bytes)?; + match ct { + ContainerType::Data => { + // Check if we need to receive more data for this container. + // The length field in the header tells us the total container size. + if bytes.len() >= 4 { + let total_length = unpack_u32(&bytes[0..4])? as usize; + // Keep receiving until we have the complete container + while bytes.len() < total_length { + let more = self.transport.receive_bulk(64 * 1024).await?; + if more.is_empty() { + return Err(Error::invalid_data( + "Incomplete data container: device stopped sending", + )); + } + bytes.extend_from_slice(&more); + } + } + let container = DataContainer::from_bytes(&bytes)?; + data.extend_from_slice(&container.payload); + // Continue to receive more containers or response + } + ContainerType::Response => { + let response = ResponseContainer::from_bytes(&bytes)?; + if response.transaction_id != tx_id { + return Err(Error::invalid_data(format!( + "Transaction ID mismatch: expected {}, got {}", + tx_id, response.transaction_id + ))); + } + return Ok((response, data)); + } + _ => { + return Err(Error::invalid_data(format!( + "Unexpected container type: {:?}", + ct + ))); + } + } + } + } + + /// Execute a PTP operation with a data send phase. + /// + /// Sends the provided payload to the device as part of the operation. + /// Useful for vendor-specific operations that require sending data. + /// + /// When [`is_split_header_data`](Self::is_split_header_data) is enabled, the + /// 12-byte PTP container header and the payload are sent as separate USB + /// bulk transfers. + pub async fn execute_with_send( + &self, + operation: OperationCode, + params: &[u32], + data: &[u8], + ) -> Result { + let _guard = self.operation_lock.lock().await; + + let tx_id = self.next_transaction_id(); + + // Send command + let cmd = CommandContainer { + code: operation, + transaction_id: tx_id, + params: params.to_vec(), + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + // Send data container + if self.split_header_data.load(Ordering::Relaxed) { + // Split mode: send the 12-byte header and the payload as two + // separate USB bulk transfers. Required by some devices. + let total_len = (HEADER_SIZE + data.len()) as u32; + let mut header = Vec::with_capacity(HEADER_SIZE); + header.extend_from_slice(&pack_u32(total_len)); + header.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + header.extend_from_slice(&pack_u16(operation.into())); + header.extend_from_slice(&pack_u32(tx_id)); + self.transport.send_bulk(&header).await?; + if !data.is_empty() { + self.transport.send_bulk(data).await?; + } + } else { + let data_container = DataContainer { + code: operation, + transaction_id: tx_id, + payload: data.to_vec(), + }; + self.transport.send_bulk(&data_container.to_bytes()).await?; + } + + // Receive response + let response_bytes = self.transport.receive_bulk(512).await?; + let response = ResponseContainer::from_bytes(&response_bytes)?; + + if response.transaction_id != tx_id { + return Err(Error::invalid_data(format!( + "Transaction ID mismatch: expected {}, got {}", + tx_id, response.transaction_id + ))); + } + + Ok(response) + } + + // ========================================================================= + // Helper methods + // ========================================================================= + + /// Helper to check response is OK. + pub(crate) fn check_response( + response: &ResponseContainer, + operation: OperationCode, + ) -> Result<(), Error> { + if response.code == ResponseCode::Ok { + Ok(()) + } else { + Err(Error::Protocol { + code: response.code, + operation, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::{pack_u16, pack_u32, ContainerType, ObjectHandle}; + use crate::transport::mock::MockTransport; + + /// Create a mock transport as Arc. + pub(crate) fn mock_transport() -> (Arc, Arc) { + let mock = Arc::new(MockTransport::new()); + let transport: Arc = Arc::clone(&mock) as Arc; + (transport, mock) + } + + /// Build an OK response container bytes. + pub(crate) fn ok_response(tx_id: u32) -> Vec { + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&pack_u32(12)); // length + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf + } + + /// Build a response container with params. + pub(crate) fn response_with_params(tx_id: u32, code: ResponseCode, params: &[u32]) -> Vec { + let len = 12 + params.len() * 4; + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + for p in params { + buf.extend_from_slice(&pack_u32(*p)); + } + buf + } + + /// Build a data container. + pub(crate) fn data_container(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { + let len = 12 + payload.len(); + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(tx_id)); + buf.extend_from_slice(payload); + buf + } + + #[tokio::test] + async fn test_open_session() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession is session-less (tx_id=0) + + let session = PtpSession::open(transport, 1).await.unwrap(); + assert_eq!(session.session_id(), SessionId(1)); + } + + #[tokio::test] + async fn test_open_session_already_open_recovers() { + let (transport, mock) = mock_transport(); + + // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) + mock.queue_response(response_with_params( + 0, + ResponseCode::SessionAlreadyOpen, + &[], + )); + // CloseSession (tx_id=1, first counter value) + mock.queue_response(ok_response(1)); + // Second OpenSession on fresh session (session-less, tx_id=0) + mock.queue_response(ok_response(0)); + + // Should succeed by closing and reopening + let session = PtpSession::open(transport, 1).await.unwrap(); + assert_eq!(session.session_id(), SessionId(1)); + } + + #[tokio::test] + async fn test_open_session_already_open_reuses_without_close() { + let (transport, mock) = mock_transport(); + mock.queue_response(response_with_params( + 0, + ResponseCode::SessionAlreadyOpen, + &[], + )); + + let session = PtpSession::open_reusing_existing(transport, 0xBAAA_AAAD) + .await + .unwrap(); + + assert_eq!(session.session_id(), SessionId(0xBAAA_AAAD)); + assert_eq!(mock.get_sends().len(), 1, "must not send CloseSession"); + } + + #[tokio::test] + async fn test_open_session_already_open_transaction_id_reset() { + let (transport, mock) = mock_transport(); + + // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) + mock.queue_response(response_with_params( + 0, + ResponseCode::SessionAlreadyOpen, + &[], + )); + // CloseSession uses tx_id=1 (first counter value, OpenSession didn't consume one) + mock.queue_response(ok_response(1)); + // Second OpenSession on fresh session (session-less, tx_id=0) + mock.queue_response(ok_response(0)); + // First operation on fresh session uses tx_id=1 + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + + // Verify the fresh session's counter starts at 1 + session.delete_object(ObjectHandle(1)).await.unwrap(); + } + + #[tokio::test] + async fn test_open_session_already_open_close_error_ignored() { + let (transport, mock) = mock_transport(); + + // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) + mock.queue_response(response_with_params( + 0, + ResponseCode::SessionAlreadyOpen, + &[], + )); + // CloseSession returns an error (tx_id=1, should be ignored) + mock.queue_response(response_with_params(1, ResponseCode::GeneralError, &[])); + // Second OpenSession succeeds (session-less, tx_id=0) + mock.queue_response(ok_response(0)); + + // Should succeed even if CloseSession fails + let session = PtpSession::open(transport, 1).await.unwrap(); + assert_eq!(session.session_id(), SessionId(1)); + } + + #[tokio::test] + async fn test_open_session_error() { + let (transport, mock) = mock_transport(); + mock.queue_response(response_with_params(0, ResponseCode::GeneralError, &[])); + + let result = PtpSession::open(transport, 1).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_transaction_id_increment() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) + mock.queue_response(ok_response(1)); // First operation + mock.queue_response(ok_response(2)); // Second operation + + let session = PtpSession::open(transport, 1).await.unwrap(); + + // First post-open operation uses tx_id=1, second uses tx_id=2 + session.delete_object(ObjectHandle(1)).await.unwrap(); + session.delete_object(ObjectHandle(2)).await.unwrap(); + } + + #[tokio::test] + async fn test_transaction_id_mismatch() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) + mock.queue_response(ok_response(999)); // Wrong transaction ID + + let session = PtpSession::open(transport, 1).await.unwrap(); + let result = session.delete_object(ObjectHandle(1)).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_with_send_combined_default() { + // By default, command + combined data container = 2 bulk sends. + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // operation response + + let session = PtpSession::open(transport, 1).await.unwrap(); + assert!(!session.is_split_header_data()); + + let payload = vec![0xAA, 0xBB, 0xCC, 0xDD]; + session + .execute_with_send(OperationCode::SendObject, &[], &payload) + .await + .unwrap(); + + // 1 send for OpenSession command + 2 here = 3 total. + let sends = mock.get_sends(); + assert_eq!(sends.len(), 3); + + // Third send is the combined data container: 12-byte header + 4-byte payload. + let data = &sends[2]; + assert_eq!(data.len(), HEADER_SIZE + payload.len()); + assert_eq!(unpack_u32(&data[0..4]).unwrap() as usize, data.len()); + assert_eq!(&data[HEADER_SIZE..], payload.as_slice()); + } + + #[tokio::test] + async fn test_execute_with_send_split_header_data() { + // With split mode enabled, header and payload are sent as 2 separate transfers. + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // operation response + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.set_split_header_data(true); + assert!(session.is_split_header_data()); + + let payload = vec![0xAA, 0xBB, 0xCC, 0xDD]; + session + .execute_with_send(OperationCode::SendObject, &[], &payload) + .await + .unwrap(); + + // OpenSession (1) + command (1) + header (1) + payload (1) = 4 sends. + let sends = mock.get_sends(); + assert_eq!(sends.len(), 4); + + // Third send is the bare 12-byte header reporting the full container length. + let header = &sends[2]; + assert_eq!(header.len(), HEADER_SIZE); + assert_eq!( + unpack_u32(&header[0..4]).unwrap() as usize, + HEADER_SIZE + payload.len() + ); + + // Fourth send is the raw payload, no extra framing. + assert_eq!(sends[3], payload); + } + + #[tokio::test] + async fn test_execute_with_send_split_empty_payload() { + // With split mode and an empty payload, only the header is sent (no second transfer). + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.set_split_header_data(true); + + session + .execute_with_send(OperationCode::SendObject, &[], &[]) + .await + .unwrap(); + + // OpenSession + command + header only = 3. + let sends = mock.get_sends(); + assert_eq!(sends.len(), 3); + assert_eq!(sends[2].len(), HEADER_SIZE); + } + + #[tokio::test] + async fn test_execute_with_send_stream_combined_default() { + // Combined mode batches header + data into 1 MB USB transfers. + // A small payload fits entirely in one batch. + use bytes::Bytes; + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // operation response + + let session = PtpSession::open(transport, 1).await.unwrap(); + assert!(!session.is_split_header_data()); + + let chunks: Vec> = vec![ + Ok(Bytes::from_static(&[0xAA, 0xBB])), + Ok(Bytes::from_static(&[0xCC, 0xDD])), + ]; + let total_size = 4u64; + + session + .execute_with_send_stream( + OperationCode::SendObject, + &[], + total_size, + stream::iter(chunks), + ) + .await + .unwrap(); + + // OpenSession (1) + command (1) + one batch with header+all data (1) = 3 sends. + let sends = mock.get_sends(); + assert_eq!(sends.len(), 3); + + let data = &sends[2]; + assert_eq!(data.len(), HEADER_SIZE + total_size as usize); + assert_eq!(unpack_u32(&data[0..4]).unwrap() as usize, data.len()); + assert_eq!(&data[HEADER_SIZE..], &[0xAA, 0xBB, 0xCC, 0xDD]); + } + + #[tokio::test] + async fn test_execute_with_send_stream_combined_large_multichunk() { + // Verify combined mode works with a large payload (3 MB across 48 + // chunks of 64KB each). The mock transport uses the default + // send_bulk_streaming which buffers everything into one send_bulk + // call, so we get a single data send. Real USB transports stream + // in 256KB transfers. + use bytes::Bytes; + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // operation response + + let session = PtpSession::open(transport, 1).await.unwrap(); + + let chunk_size = 64 * 1024; + let num_chunks = 48; // 3 MB total + let total_size = (chunk_size * num_chunks) as u64; + let chunks: Vec> = (0..num_chunks) + .map(|i| Ok(Bytes::from(vec![(i % 256) as u8; chunk_size]))) + .collect(); + + session + .execute_with_send_stream( + OperationCode::SendObject, + &[], + total_size, + stream::iter(chunks), + ) + .await + .unwrap(); + + // Verify all data arrived (mock buffers everything into one send). + let sends = mock.get_sends(); + // Skip first 2 sends (OpenSession command + SendObject command). + let data_sends: Vec = sends[2..].iter().flat_map(|s| s.clone()).collect(); + assert_eq!(data_sends.len(), HEADER_SIZE + total_size as usize); + + let payload = &data_sends[HEADER_SIZE..]; + for i in 0..num_chunks { + let chunk_start = i * chunk_size; + let chunk_end = chunk_start + chunk_size; + assert!( + payload[chunk_start..chunk_end] + .iter() + .all(|&b| b == (i % 256) as u8), + "chunk {i} data mismatch" + ); + } + } + + #[tokio::test] + async fn test_execute_with_send_stream_split_header_data() { + // With split mode enabled, the header is sent first as its own bulk + // transfer and then each non-empty chunk is sent as its own transfer. + use bytes::Bytes; + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // operation response + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.set_split_header_data(true); + + let chunks: Vec> = vec![ + Ok(Bytes::from_static(&[0xAA, 0xBB])), + Ok(Bytes::from_static(&[0xCC, 0xDD])), + ]; + let total_size = 4u64; + + session + .execute_with_send_stream( + OperationCode::SendObject, + &[], + total_size, + stream::iter(chunks), + ) + .await + .unwrap(); + + // OpenSession (1) + command (1) + header (1) + chunk1 (1) + chunk2 (1) = 5. + let sends = mock.get_sends(); + assert_eq!(sends.len(), 5); + + // Third send is the bare 12-byte header reporting the full container length. + let header = &sends[2]; + assert_eq!(header.len(), HEADER_SIZE); + assert_eq!( + unpack_u32(&header[0..4]).unwrap() as usize, + HEADER_SIZE + total_size as usize + ); + + // Fourth and fifth sends are the raw chunks, no extra framing. + assert_eq!(sends[3], &[0xAA, 0xBB]); + assert_eq!(sends[4], &[0xCC, 0xDD]); + } + + #[tokio::test] + async fn test_execute_with_send_stream_split_skips_empty_chunks() { + // Empty chunks in split mode must not be sent as zero-length bulk + // transfers (which some devices treat as end-of-transfer markers). + use bytes::Bytes; + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.set_split_header_data(true); + + let chunks: Vec> = vec![ + Ok(Bytes::from_static(&[0xAA])), + Ok(Bytes::new()), // empty — should be skipped + Ok(Bytes::from_static(&[0xBB])), + ]; + let total_size = 2u64; + + session + .execute_with_send_stream( + OperationCode::SendObject, + &[], + total_size, + stream::iter(chunks), + ) + .await + .unwrap(); + + // OpenSession + command + header + chunk1 + chunk3 = 5 (the empty + // chunk in the middle must not produce a send). + let sends = mock.get_sends(); + assert_eq!(sends.len(), 5); + assert_eq!(sends[2].len(), HEADER_SIZE); + assert_eq!(sends[3], &[0xAA]); + assert_eq!(sends[4], &[0xBB]); + } + + #[tokio::test] + async fn test_close_session() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) + mock.queue_response(ok_response(1)); // CloseSession (tx_id=1) + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn test_close_session_ignores_errors() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) + mock.queue_response(response_with_params(1, ResponseCode::GeneralError, &[])); // CloseSession error (tx_id=1) + + let session = PtpSession::open(transport, 1).await.unwrap(); + // Should succeed even if close fails + session.close().await.unwrap(); + } +} diff --git a/vendor/mtp-rs/src/ptp/session/operations.rs b/vendor/mtp-rs/src/ptp/session/operations.rs new file mode 100644 index 0000000..6c314b4 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/session/operations.rs @@ -0,0 +1,798 @@ +//! High-level PTP/MTP operations. +//! +//! This module contains methods for common operations like getting device info, +//! listing storage and objects, downloading/uploading files, etc. + +use crate::ptp::{ + pack_string, unpack_u32_array, unpack_u64, DeviceInfo, EventContainer, ObjectFormatCode, + ObjectHandle, ObjectInfo, ObjectPropertyCode, OperationCode, StorageId, StorageInfo, +}; +use crate::Error; + +use super::PtpSession; + +impl PtpSession { + /// Returns information about the device including its capabilities, + /// manufacturer, model, and supported operations. + pub async fn get_device_info(&self) -> Result { + let (response, data) = self + .execute_with_receive(OperationCode::GetDeviceInfo, &[]) + .await?; + Self::check_response(&response, OperationCode::GetDeviceInfo)?; + DeviceInfo::from_bytes(&data) + } + + /// Returns a list of storage IDs available on the device. + pub async fn get_storage_ids(&self) -> Result, Error> { + let (response, data) = self + .execute_with_receive(OperationCode::GetStorageIds, &[]) + .await?; + Self::check_response(&response, OperationCode::GetStorageIds)?; + let (ids, _) = unpack_u32_array(&data)?; + Ok(ids.into_iter().map(StorageId).collect()) + } + + /// Returns information about a specific storage, including capacity, + /// free space, and filesystem type. + pub async fn get_storage_info(&self, storage_id: StorageId) -> Result { + let (response, data) = self + .execute_with_receive(OperationCode::GetStorageInfo, &[storage_id.0]) + .await?; + Self::check_response(&response, OperationCode::GetStorageInfo)?; + StorageInfo::from_bytes(&data) + } + + /// Get object handles. + /// + /// Returns a list of object handles matching the specified criteria. + /// + /// # Arguments + /// + /// * `storage_id` - Storage to search, or `StorageId::ALL` for all storages + /// * `format` - Filter by format, or `None` for all formats + /// * `parent` - Parent folder handle, or `None` for root level only, + /// or `Some(ObjectHandle::ALL)` for recursive listing + pub async fn get_object_handles( + &self, + storage_id: StorageId, + format: Option, + parent: Option, + ) -> Result, Error> { + let format_code = format.map(|f| u16::from(f) as u32).unwrap_or(0); + let parent_handle = parent.map(|p| p.0).unwrap_or(0); // 0 = root only + + let (response, data) = self + .execute_with_receive( + OperationCode::GetObjectHandles, + &[storage_id.0, format_code, parent_handle], + ) + .await?; + Self::check_response(&response, OperationCode::GetObjectHandles)?; + let (handles, _) = unpack_u32_array(&data)?; + Ok(handles.into_iter().map(ObjectHandle).collect()) + } + + /// Returns metadata about an object, including filename, size, and timestamps. + /// + /// Note: the `size` field is parsed from the standard `ObjectInfo` dataset, which + /// uses a u32 for `ObjectCompressedSize` and saturates at `u32::MAX` for files + /// larger than 4 GB. Use [`get_object_info_full`](Self::get_object_info_full) + /// to auto-resolve the real u64 size via `GetObjectPropValue`. + pub async fn get_object_info(&self, handle: ObjectHandle) -> Result { + let (response, data) = self + .execute_with_receive(OperationCode::GetObjectInfo, &[handle.0]) + .await?; + Self::check_response(&response, OperationCode::GetObjectInfo)?; + ObjectInfo::from_bytes(&data) + } + + /// Returns object metadata with the full 64-bit size resolved. + /// + /// Calls [`get_object_info`](Self::get_object_info) first. If the returned + /// `size` is saturated at `u32::MAX` (indicating the file is larger than 4 GB), + /// it follows up with `GetObjectPropValue(ObjectSize)` to retrieve the real + /// u64 size and overwrites the field before returning. + /// + /// Falls back to the saturated value if the device doesn't support + /// `GetObjectPropValue`, doesn't support the `ObjectSize` property, or if + /// the response can't be parsed. Transport-level errors from the follow-up + /// are swallowed so that listing operations aren't aborted by an optional + /// property lookup; the saturated size is returned instead. + pub async fn get_object_info_full(&self, handle: ObjectHandle) -> Result { + let mut info = self.get_object_info(handle).await?; + if info.size == u64::from(u32::MAX) { + if let Ok(bytes) = self + .get_object_prop_value(handle, ObjectPropertyCode::ObjectSize) + .await + { + if let Ok(real_size) = unpack_u64(&bytes) { + info.size = real_size; + } + } + // Any error (op not supported, prop not supported, parse failure, transport + // error) leaves `info.size` at the saturated u32::MAX. Callers that need the + // exact size for >4 GB objects on devices without GetObjectPropValue support + // must fetch it through another mechanism. + } + Ok(info) + } + + /// Downloads the complete data of an object. + pub async fn get_object(&self, handle: ObjectHandle) -> Result, Error> { + let (response, data) = self + .execute_with_receive(OperationCode::GetObject, &[handle.0]) + .await?; + Self::check_response(&response, OperationCode::GetObject)?; + Ok(data) + } + + /// Get partial object. + /// + /// Downloads a portion of an object's data. Uses the standard `GetPartialObject` + /// operation, which has a 32-bit offset — offsets beyond 4 GB are truncated to + /// u32 and will silently wrap. For files larger than 4 GB, use + /// [`get_partial_object_64()`](Self::get_partial_object_64) instead. + /// + /// # Arguments + /// + /// * `handle` - The object handle + /// * `offset` - Byte offset to start from (truncated to u32) + /// * `max_bytes` - Maximum number of bytes to retrieve + pub async fn get_partial_object( + &self, + handle: ObjectHandle, + offset: u64, + max_bytes: u32, + ) -> Result, Error> { + // GetPartialObject params: handle, offset (u32), max_bytes (u32) + // Note: offset is truncated to u32 in standard MTP + let (response, data) = self + .execute_with_receive( + OperationCode::GetPartialObject, + &[handle.0, offset as u32, max_bytes], + ) + .await?; + Self::check_response(&response, OperationCode::GetPartialObject)?; + Ok(data) + } + + /// Get partial object with a 64-bit offset. + /// + /// Uses the Android/MTP extension `GetPartialObject64` (`0x95C1`) which supports + /// offsets beyond 4 GB. Only devices that advertise this op in their + /// `DeviceInfo::operations_supported` list will accept it; others return + /// `OperationNotSupported`. + /// + /// # Arguments + /// + /// * `handle` - The object handle + /// * `offset` - 64-bit byte offset to start from + /// * `max_bytes` - Maximum number of bytes to retrieve + pub async fn get_partial_object_64( + &self, + handle: ObjectHandle, + offset: u64, + max_bytes: u32, + ) -> Result, Error> { + // GetPartialObject64 params: handle, offset_lo (u32), offset_hi (u32), max_bytes (u32) + let offset_lo = offset as u32; + let offset_hi = (offset >> 32) as u32; + let (response, data) = self + .execute_with_receive( + OperationCode::GetPartialObject64, + &[handle.0, offset_lo, offset_hi, max_bytes], + ) + .await?; + Self::check_response(&response, OperationCode::GetPartialObject64)?; + Ok(data) + } + + /// Downloads the thumbnail image for an object. + pub async fn get_thumb(&self, handle: ObjectHandle) -> Result, Error> { + let (response, data) = self + .execute_with_receive(OperationCode::GetThumb, &[handle.0]) + .await?; + Self::check_response(&response, OperationCode::GetThumb)?; + Ok(data) + } + + /// Send object info (prepare for upload). + /// + /// This must be called before `send_object()` to prepare the device for + /// receiving a new object. + /// + /// # Returns + /// + /// Returns a tuple of (storage_id, parent_handle, new_object_handle) where: + /// - `storage_id` - The storage where the object will be created + /// - `parent_handle` - The parent folder handle + /// - `new_object_handle` - The handle assigned to the new object + pub async fn send_object_info( + &self, + storage_id: StorageId, + parent: ObjectHandle, + info: &ObjectInfo, + ) -> Result<(StorageId, ObjectHandle, ObjectHandle), Error> { + let data = info.to_bytes()?; + let response = self + .execute_with_send( + OperationCode::SendObjectInfo, + &[storage_id.0, parent.0], + &data, + ) + .await?; + Self::check_response(&response, OperationCode::SendObjectInfo)?; + + // Response params: storage_id, parent_handle, object_handle + if response.params.len() < 3 { + return Err(Error::invalid_data( + "SendObjectInfo response missing params", + )); + } + Ok(( + StorageId(response.params[0]), + ObjectHandle(response.params[1]), + ObjectHandle(response.params[2]), + )) + } + + /// Send object data (must follow send_object_info). + /// + /// Uploads the actual data for an object. This must be called immediately + /// after `send_object_info()`. + pub async fn send_object(&self, data: &[u8]) -> Result<(), Error> { + let response = self + .execute_with_send(OperationCode::SendObject, &[], data) + .await?; + Self::check_response(&response, OperationCode::SendObject)?; + Ok(()) + } + + /// Deletes an object from the device. + pub async fn delete_object(&self, handle: ObjectHandle) -> Result<(), Error> { + // Param2 is format code, 0 means any format + let response = self + .execute(OperationCode::DeleteObject, &[handle.0, 0]) + .await?; + Self::check_response(&response, OperationCode::DeleteObject)?; + Ok(()) + } + + /// Moves an object to a different location. + pub async fn move_object( + &self, + handle: ObjectHandle, + storage_id: StorageId, + parent: ObjectHandle, + ) -> Result<(), Error> { + let response = self + .execute( + OperationCode::MoveObject, + &[handle.0, storage_id.0, parent.0], + ) + .await?; + Self::check_response(&response, OperationCode::MoveObject)?; + Ok(()) + } + + /// Copies an object to a new location. + /// Returns the handle of the newly created copy. + pub async fn copy_object( + &self, + handle: ObjectHandle, + storage_id: StorageId, + parent: ObjectHandle, + ) -> Result { + let response = self + .execute( + OperationCode::CopyObject, + &[handle.0, storage_id.0, parent.0], + ) + .await?; + Self::check_response(&response, OperationCode::CopyObject)?; + + if response.params.is_empty() { + return Err(Error::invalid_data("CopyObject response missing handle")); + } + Ok(ObjectHandle(response.params[0])) + } + + /// Get object property value. + /// + /// Retrieves the value of a specific property for an object. + /// This is an MTP extension operation (0x9803). + /// + /// # Arguments + /// + /// * `handle` - The object handle + /// * `property` - The property code to retrieve + /// + /// # Returns + /// + /// Returns the raw property value as bytes. + pub async fn get_object_prop_value( + &self, + handle: ObjectHandle, + property: ObjectPropertyCode, + ) -> Result, Error> { + let (response, data) = self + .execute_with_receive( + OperationCode::GetObjectPropValue, + &[handle.0, u16::from(property) as u32], + ) + .await?; + Self::check_response(&response, OperationCode::GetObjectPropValue)?; + Ok(data) + } + + /// Set object property value. + /// + /// Sets the value of a specific property for an object. + /// This is an MTP extension operation (0x9804). + /// + /// # Arguments + /// + /// * `handle` - The object handle + /// * `property` - The property code to set + /// * `value` - The raw property value as bytes + pub async fn set_object_prop_value( + &self, + handle: ObjectHandle, + property: ObjectPropertyCode, + value: &[u8], + ) -> Result<(), Error> { + let response = self + .execute_with_send( + OperationCode::SetObjectPropValue, + &[handle.0, u16::from(property) as u32], + value, + ) + .await?; + Self::check_response(&response, OperationCode::SetObjectPropValue)?; + Ok(()) + } + + /// Rename an object (file or folder). + /// + /// This is a convenience method that uses SetObjectPropValue to change + /// the ObjectFileName property (0xDC07). + /// + /// # Arguments + /// + /// * `handle` - The object handle to rename + /// * `new_name` - The new filename + /// + /// # Note + /// + /// Not all devices support renaming. Check `supports_rename()` on DeviceInfo first. + pub async fn rename_object(&self, handle: ObjectHandle, new_name: &str) -> Result<(), Error> { + let name_bytes = pack_string(new_name); + self.set_object_prop_value(handle, ObjectPropertyCode::ObjectFileName, &name_bytes) + .await + } + + // --- Capture operations --- + + /// Initiate a capture operation. + /// + /// This triggers the camera to capture an image. The operation is asynchronous; + /// use `poll_event()` to wait for `CaptureComplete` and `ObjectAdded` events. + /// + /// # Arguments + /// + /// * `storage_id` - Target storage (use `StorageId(0)` for camera default) + /// * `format` - Object format for the capture (use `ObjectFormatCode::Undefined` + /// for camera default) + /// + /// # Events + /// + /// After calling this method, monitor for these events: + /// - `EventCode::CaptureComplete` - Capture operation finished + /// - `EventCode::ObjectAdded` - New object (image) was created on device + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::ptp::{PtpDevice, EventCode}; + /// use mtp_rs::{StorageId, ObjectFormatCode}; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// # let device = PtpDevice::open_first().await?; + /// # let session = device.open_session().await?; + /// // Trigger capture + /// session.initiate_capture(StorageId(0), ObjectFormatCode::Undefined).await?; + /// + /// // Wait for events + /// loop { + /// match session.poll_event().await? { + /// Some(event) if event.code == EventCode::CaptureComplete => { + /// println!("Capture complete!"); + /// break; + /// } + /// Some(event) if event.code == EventCode::ObjectAdded => { + /// println!("New object: {}", event.params[0]); + /// } + /// _ => continue, + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn initiate_capture( + &self, + storage_id: StorageId, + format: ObjectFormatCode, + ) -> Result<(), Error> { + // Per PTP spec, 0x00000000 means "any format" / "use device default". + // ObjectFormatCode::Undefined (0x3000) is different and may not be accepted. + let format_code = match format { + ObjectFormatCode::Undefined => 0, + other => u16::from(other) as u32, + }; + let response = self + .execute(OperationCode::InitiateCapture, &[storage_id.0, format_code]) + .await?; + Self::check_response(&response, OperationCode::InitiateCapture)?; + Ok(()) + } + + // --- Event handling --- + + /// Poll for a single event from the interrupt endpoint. + /// + /// This method awaits **indefinitely** until an event is received from the USB + /// interrupt endpoint. Events are asynchronous notifications from the device + /// about changes such as objects being added/removed, storage changes, etc. + /// Callers should wrap this in an async timeout to avoid blocking forever. + /// + /// Note: This method does not require the operation lock since events are + /// received on the interrupt endpoint, which is independent of bulk transfers. + /// + /// # Returns + /// + /// - `Ok(Some(container))` - An event was received + /// - `Ok(None)` - No event (unreachable in practice; kept for API compatibility) + /// - `Err(_)` - Communication error + pub async fn poll_event(&self) -> Result, Error> { + match self.transport.receive_interrupt().await { + Ok(bytes) => { + let container = EventContainer::from_bytes(&bytes)?; + Ok(Some(container)) + } + Err(Error::Timeout) => Ok(None), + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::session::tests::{ + data_container, mock_transport, ok_response, response_with_params, + }; + use crate::ptp::{ + pack_string, pack_u16, pack_u32, pack_u32_array, ContainerType, ResponseCode, + }; + + fn event_container(code: u16, params: [u32; 3]) -> Vec { + let mut buf = Vec::with_capacity(24); + buf.extend_from_slice(&pack_u32(24)); // length = 24 + buf.extend_from_slice(&pack_u16(ContainerType::Event.to_code())); + buf.extend_from_slice(&pack_u16(code)); + buf.extend_from_slice(&pack_u32(0)); // transaction_id + buf.extend_from_slice(&pack_u32(params[0])); + buf.extend_from_slice(&pack_u32(params[1])); + buf.extend_from_slice(&pack_u32(params[2])); + buf + } + + #[tokio::test] + async fn test_get_storage_ids() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // GetStorageIds data response + let storage_ids_data = pack_u32_array(&[0x00010001, 0x00010002]); + mock.queue_response(data_container( + 1, + OperationCode::GetStorageIds, + &storage_ids_data, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let ids = session.get_storage_ids().await.unwrap(); + + assert_eq!(ids, vec![StorageId(0x00010001), StorageId(0x00010002)]); + } + + #[tokio::test] + async fn test_get_object_handles() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // GetObjectHandles data response + let handles_data = pack_u32_array(&[1, 2, 3]); + mock.queue_response(data_container( + 1, + OperationCode::GetObjectHandles, + &handles_data, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let handles = session + .get_object_handles(StorageId::ALL, None, None) + .await + .unwrap(); + + assert_eq!( + handles, + vec![ObjectHandle(1), ObjectHandle(2), ObjectHandle(3)] + ); + } + + #[tokio::test] + async fn test_get_object() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // GetObject data response + let object_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + mock.queue_response(data_container(1, OperationCode::GetObject, &object_data)); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let data = session.get_object(ObjectHandle(1)).await.unwrap(); + + assert_eq!(data, object_data); + } + + #[tokio::test] + async fn test_delete_object() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // DeleteObject + + let session = PtpSession::open(transport, 1).await.unwrap(); + session.delete_object(ObjectHandle(1)).await.unwrap(); + } + + #[tokio::test] + async fn test_copy_object() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params(1, ResponseCode::Ok, &[100])); // CopyObject with new handle + + let session = PtpSession::open(transport, 1).await.unwrap(); + let new_handle = session + .copy_object(ObjectHandle(1), StorageId(0x00010001), ObjectHandle::ROOT) + .await + .unwrap(); + + assert_eq!(new_handle, ObjectHandle(100)); + } + + // --- Event polling tests --- + + #[tokio::test] + async fn test_poll_event_object_added() { + use crate::ptp::EventCode; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue an ObjectAdded event (code 0x4002) + mock.queue_interrupt(event_container(0x4002, [42, 0, 0])); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let event = session.poll_event().await.unwrap().unwrap(); + + assert_eq!(event.code, EventCode::ObjectAdded); + assert_eq!(event.params[0], 42); + } + + #[tokio::test] + async fn test_poll_event_store_removed() { + use crate::ptp::EventCode; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue a StoreRemoved event (code 0x4005) + mock.queue_interrupt(event_container(0x4005, [0x00010001, 0, 0])); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let event = session.poll_event().await.unwrap().unwrap(); + + assert_eq!(event.code, EventCode::StoreRemoved); + assert_eq!(event.params[0], 0x00010001); + } + + #[tokio::test] + async fn test_poll_event_multiple_events() { + use crate::ptp::EventCode; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue multiple events + mock.queue_interrupt(event_container(0x4002, [1, 0, 0])); // ObjectAdded + mock.queue_interrupt(event_container(0x4002, [2, 0, 0])); // ObjectAdded + mock.queue_interrupt(event_container(0x4003, [1, 0, 0])); // ObjectRemoved + + let session = PtpSession::open(transport, 1).await.unwrap(); + + let event1 = session.poll_event().await.unwrap().unwrap(); + assert_eq!(event1.code, EventCode::ObjectAdded); + assert_eq!(event1.params[0], 1); + + let event2 = session.poll_event().await.unwrap().unwrap(); + assert_eq!(event2.code, EventCode::ObjectAdded); + assert_eq!(event2.params[0], 2); + + let event3 = session.poll_event().await.unwrap().unwrap(); + assert_eq!(event3.code, EventCode::ObjectRemoved); + assert_eq!(event3.params[0], 1); + } + + // --- Object property and rename tests --- + + #[tokio::test] + async fn test_get_object_prop_value() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // GetObjectPropValue data response (property value is raw bytes) + let prop_value = vec![0x05, 0x48, 0x00, 0x69, 0x00, 0x00, 0x00]; // Packed string "Hi" + mock.queue_response(data_container( + 1, + OperationCode::GetObjectPropValue, + &prop_value, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let data = session + .get_object_prop_value(ObjectHandle(1), ObjectPropertyCode::ObjectFileName) + .await + .unwrap(); + + assert_eq!(data, prop_value); + } + + #[tokio::test] + async fn test_set_object_prop_value() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SetObjectPropValue + + let session = PtpSession::open(transport, 1).await.unwrap(); + let prop_value = pack_string("newfile.txt"); + session + .set_object_prop_value( + ObjectHandle(1), + ObjectPropertyCode::ObjectFileName, + &prop_value, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_set_object_prop_value_not_supported() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params( + 1, + ResponseCode::OperationNotSupported, + &[], + )); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let prop_value = pack_string("newfile.txt"); + let result = session + .set_object_prop_value( + ObjectHandle(1), + ObjectPropertyCode::ObjectFileName, + &prop_value, + ) + .await; + + assert!(matches!( + result, + Err(crate::Error::Protocol { + code: ResponseCode::OperationNotSupported, + .. + }) + )); + } + + #[tokio::test] + async fn test_rename_object() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SetObjectPropValue (for rename) + + let session = PtpSession::open(transport, 1).await.unwrap(); + session + .rename_object(ObjectHandle(1), "renamed.txt") + .await + .unwrap(); + } + + #[tokio::test] + async fn test_rename_object_not_supported() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params( + 1, + ResponseCode::OperationNotSupported, + &[], + )); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let result = session.rename_object(ObjectHandle(1), "renamed.txt").await; + + assert!(matches!( + result, + Err(crate::Error::Protocol { + code: ResponseCode::OperationNotSupported, + .. + }) + )); + } + + // --- Capture tests --- + + #[tokio::test] + async fn test_initiate_capture() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // InitiateCapture + + let session = PtpSession::open(transport, 1).await.unwrap(); + session + .initiate_capture(StorageId(0), ObjectFormatCode::Undefined) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_initiate_capture_with_format() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // InitiateCapture + + let session = PtpSession::open(transport, 1).await.unwrap(); + session + .initiate_capture(StorageId(0x00010001), ObjectFormatCode::Jpeg) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_initiate_capture_not_supported() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params( + 1, + ResponseCode::OperationNotSupported, + &[], + )); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let result = session + .initiate_capture(StorageId(0), ObjectFormatCode::Undefined) + .await; + + assert!(matches!( + result, + Err(crate::Error::Protocol { + code: ResponseCode::OperationNotSupported, + .. + }) + )); + } +} diff --git a/vendor/mtp-rs/src/ptp/session/properties.rs b/vendor/mtp-rs/src/ptp/session/properties.rs new file mode 100644 index 0000000..d13851c --- /dev/null +++ b/vendor/mtp-rs/src/ptp/session/properties.rs @@ -0,0 +1,354 @@ +//! Device property operations. +//! +//! This module contains methods for getting and setting device properties, +//! primarily used for digital cameras to query/set settings like ISO, aperture, etc. + +use crate::ptp::{ + DevicePropDesc, DevicePropertyCode, OperationCode, PropertyDataType, PropertyValue, +}; +use crate::Error; + +use super::PtpSession; + +impl PtpSession { + // ========================================================================= + // Device property operations + // ========================================================================= + + /// Get the descriptor for a device property. + /// + /// Returns detailed information about the property including its type, + /// current value, default value, and allowed values/range. + /// + /// This is primarily used for digital cameras to query settings like + /// ISO, aperture, shutter speed, etc. Most Android MTP devices do not + /// support device properties. + /// + /// # Arguments + /// + /// * `property` - The device property code to query + /// + /// # Example + /// + /// ```rust,no_run + /// use mtp_rs::ptp::{PtpDevice, DevicePropertyCode}; + /// + /// # async fn example() -> Result<(), mtp_rs::Error> { + /// # let device = PtpDevice::open_first().await?; + /// # let session = device.open_session().await?; + /// let desc = session.get_device_prop_desc(DevicePropertyCode::BatteryLevel).await?; + /// println!("Battery level: {:?}", desc.current_value); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_device_prop_desc( + &self, + property: DevicePropertyCode, + ) -> Result { + let (response, data) = self + .execute_with_receive( + OperationCode::GetDevicePropDesc, + &[u16::from(property) as u32], + ) + .await?; + Self::check_response(&response, OperationCode::GetDevicePropDesc)?; + DevicePropDesc::from_bytes(&data) + } + + /// Get the current value of a device property. + /// + /// Returns the raw bytes of the property value. To interpret the value, + /// you need to know the property's data type. Use `get_device_prop_desc()` + /// to get the full descriptor including the data type. + /// + /// # Arguments + /// + /// * `property` - The device property code to query + pub async fn get_device_prop_value( + &self, + property: DevicePropertyCode, + ) -> Result, Error> { + let (response, data) = self + .execute_with_receive( + OperationCode::GetDevicePropValue, + &[u16::from(property) as u32], + ) + .await?; + Self::check_response(&response, OperationCode::GetDevicePropValue)?; + Ok(data) + } + + /// Get a device property value as a typed PropertyValue. + /// + /// This is a convenience method that parses the raw bytes according to + /// the specified data type. + /// + /// # Arguments + /// + /// * `property` - The device property code to query + /// * `data_type` - The expected data type of the property + pub async fn get_device_prop_value_typed( + &self, + property: DevicePropertyCode, + data_type: PropertyDataType, + ) -> Result { + let data = self.get_device_prop_value(property).await?; + let (value, _) = PropertyValue::from_bytes(&data, data_type)?; + Ok(value) + } + + /// Set a device property value. + /// + /// The value should be the raw bytes of the new value. The value type + /// must match the property's data type. + /// + /// # Arguments + /// + /// * `property` - The device property code to set + /// * `value` - The raw bytes of the new value + pub async fn set_device_prop_value( + &self, + property: DevicePropertyCode, + value: &[u8], + ) -> Result<(), Error> { + let response = self + .execute_with_send( + OperationCode::SetDevicePropValue, + &[u16::from(property) as u32], + value, + ) + .await?; + Self::check_response(&response, OperationCode::SetDevicePropValue)?; + Ok(()) + } + + /// Set a device property value from a PropertyValue. + /// + /// This is a convenience method that serializes the PropertyValue to bytes. + /// + /// # Arguments + /// + /// * `property` - The device property code to set + /// * `value` - The new value + pub async fn set_device_prop_value_typed( + &self, + property: DevicePropertyCode, + value: &PropertyValue, + ) -> Result<(), Error> { + let data = value.to_bytes(); + self.set_device_prop_value(property, &data).await + } + + /// Reset a device property to its default value. + /// + /// # Arguments + /// + /// * `property` - The device property code to reset + pub async fn reset_device_prop_value(&self, property: DevicePropertyCode) -> Result<(), Error> { + let response = self + .execute( + OperationCode::ResetDevicePropValue, + &[u16::from(property) as u32], + ) + .await?; + Self::check_response(&response, OperationCode::ResetDevicePropValue)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::session::tests::{ + data_container, mock_transport, ok_response, response_with_params, + }; + use crate::ptp::{pack_u16, ResponseCode}; + + /// Build a battery level property descriptor for testing. + fn build_battery_prop_desc(current: u8) -> Vec { + let mut buf = Vec::new(); + // PropertyCode: 0x5001 (BatteryLevel) + buf.extend_from_slice(&pack_u16(0x5001)); + // DataType: UINT8 (0x0002) + buf.extend_from_slice(&pack_u16(0x0002)); + // GetSet: read-only (0x00) + buf.push(0x00); + // DefaultValue: 100 + buf.push(100); + // CurrentValue + buf.push(current); + // FormFlag: Range (0x01) + buf.push(0x01); + // Range: min=0, max=100, step=1 + buf.push(0); // min + buf.push(100); // max + buf.push(1); // step + buf + } + + #[tokio::test] + async fn test_get_device_prop_desc() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue battery level prop desc + let prop_desc_data = build_battery_prop_desc(75); + mock.queue_response(data_container( + 1, + OperationCode::GetDevicePropDesc, + &prop_desc_data, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let desc = session + .get_device_prop_desc(DevicePropertyCode::BatteryLevel) + .await + .unwrap(); + + assert_eq!(desc.property_code, DevicePropertyCode::BatteryLevel); + assert_eq!(desc.data_type, PropertyDataType::Uint8); + assert!(!desc.writable); + assert_eq!(desc.current_value, PropertyValue::Uint8(75)); + } + + #[tokio::test] + async fn test_get_device_prop_desc_not_supported() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params( + 1, + ResponseCode::DevicePropNotSupported, + &[], + )); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let result = session + .get_device_prop_desc(DevicePropertyCode::BatteryLevel) + .await; + + assert!(matches!( + result, + Err(crate::Error::Protocol { + code: ResponseCode::DevicePropNotSupported, + .. + }) + )); + } + + #[tokio::test] + async fn test_get_device_prop_value() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue battery level value (75%) + let value_data = vec![75u8]; + mock.queue_response(data_container( + 1, + OperationCode::GetDevicePropValue, + &value_data, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let data = session + .get_device_prop_value(DevicePropertyCode::BatteryLevel) + .await + .unwrap(); + + assert_eq!(data, vec![75u8]); + } + + #[tokio::test] + async fn test_get_device_prop_value_typed() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Queue ISO value (400 = 0x0190) + let value_data = vec![0x90, 0x01]; // 400 in little-endian + mock.queue_response(data_container( + 1, + OperationCode::GetDevicePropValue, + &value_data, + )); + mock.queue_response(ok_response(1)); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let value = session + .get_device_prop_value_typed( + DevicePropertyCode::ExposureIndex, + PropertyDataType::Uint16, + ) + .await + .unwrap(); + + assert_eq!(value, PropertyValue::Uint16(400)); + } + + #[tokio::test] + async fn test_set_device_prop_value() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SetDevicePropValue + + let session = PtpSession::open(transport, 1).await.unwrap(); + let value = vec![0x90, 0x01]; // 400 in little-endian + session + .set_device_prop_value(DevicePropertyCode::ExposureIndex, &value) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_set_device_prop_value_typed() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SetDevicePropValue + + let session = PtpSession::open(transport, 1).await.unwrap(); + session + .set_device_prop_value_typed( + DevicePropertyCode::ExposureIndex, + &PropertyValue::Uint16(400), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_set_device_prop_value_invalid() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(response_with_params( + 1, + ResponseCode::InvalidDevicePropValue, + &[], + )); + + let session = PtpSession::open(transport, 1).await.unwrap(); + let result = session + .set_device_prop_value(DevicePropertyCode::ExposureIndex, &[0x00, 0x00]) + .await; + + assert!(matches!( + result, + Err(crate::Error::Protocol { + code: ResponseCode::InvalidDevicePropValue, + .. + }) + )); + } + + #[tokio::test] + async fn test_reset_device_prop_value() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // ResetDevicePropValue + + let session = PtpSession::open(transport, 1).await.unwrap(); + session + .reset_device_prop_value(DevicePropertyCode::ExposureIndex) + .await + .unwrap(); + } +} diff --git a/vendor/mtp-rs/src/ptp/session/streaming.rs b/vendor/mtp-rs/src/ptp/session/streaming.rs new file mode 100644 index 0000000..1a26510 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/session/streaming.rs @@ -0,0 +1,657 @@ +//! Streaming transfer operations. +//! +//! This module contains the `ReceiveStream` struct and methods for streaming +//! data transfers, allowing memory-efficient downloads and uploads without +//! buffering entire files in memory. + +use crate::ptp::{ + container_type, pack_u16, pack_u32, unpack_u32, CommandContainer, ContainerType, ObjectHandle, + OperationCode, ResponseCode, ResponseContainer, +}; +use crate::transport::Transport; +use crate::Error; +use bytes::Bytes; +use futures::lock::OwnedMutexGuard; +use futures::Stream; +use std::sync::Arc; +use std::time::Duration; + +use super::{PtpSession, HEADER_SIZE}; + +impl PtpSession { + // ========================================================================= + // Streaming operations + // ========================================================================= + + /// Execute operation with streaming data receive. + /// + /// Returns a Stream that yields data chunks as they arrive from USB. + /// The stream yields `Bytes` chunks (typically up to 64KB each). + /// + /// # Important + /// + /// The caller must either consume the entire stream or call + /// [`cancel()`](ReceiveStream::cancel) before dropping it. The MTP + /// session is locked while the stream is active. + /// + /// # Arguments + /// + /// * `operation` - The operation code to execute + /// * `params` - Operation parameters + /// + /// # Returns + /// + /// A `ReceiveStream` that yields `Result` chunks. + pub async fn execute_with_receive_stream( + self: &Arc, + operation: OperationCode, + params: &[u32], + ) -> Result { + // Clone the Arc for the lock + let lock = Arc::clone(&self.operation_lock); + let guard = lock.lock_owned().await; + + let tx_id = self.next_transaction_id(); + + // Send command + let cmd = CommandContainer { + code: operation, + transaction_id: tx_id, + params: params.to_vec(), + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + Ok(ReceiveStream { + transport: Arc::clone(&self.transport), + _guard: guard, + transaction_id: tx_id, + operation, + buffer: Vec::new(), + container_length: 0, + payload_yielded: 0, + header_parsed: false, + done: false, + }) + } + + /// Execute operation with streaming data send. + /// + /// Accepts a Stream of data chunks to send. The total_size must be + /// known upfront (MTP protocol requirement). + /// + /// # Arguments + /// + /// * `operation` - The operation code + /// * `params` - Operation parameters + /// * `total_size` - Total bytes that will be sent (REQUIRED by MTP protocol) + /// * `data` - Stream of data chunks to send + /// + /// # Important + /// + /// The `total_size` must match the actual total bytes in the stream. + /// MTP requires knowing the size before transfer begins. + /// + /// When [`is_split_header_data`](Self::is_split_header_data) is enabled, the + /// 12-byte PTP container header and the streamed payload are sent as + /// separate USB bulk transfers, mirroring the behavior of + /// [`execute_with_send`](Self::execute_with_send). + pub async fn execute_with_send_stream( + &self, + operation: OperationCode, + params: &[u32], + total_size: u64, + mut data: S, + ) -> Result + where + S: Stream> + Unpin + Send, + { + use futures::StreamExt; + use std::sync::atomic::Ordering; + + let _guard = self.operation_lock.lock().await; + let tx_id = self.next_transaction_id(); + + // Send command + let cmd = CommandContainer { + code: operation, + transaction_id: tx_id, + params: params.to_vec(), + }; + self.transport.send_bulk(&cmd.to_bytes()).await?; + + let container_length = HEADER_SIZE as u64 + total_size; + + // Build the 12-byte data container header. + let mut header = Vec::with_capacity(HEADER_SIZE); + if container_length <= u32::MAX as u64 { + header.extend_from_slice(&pack_u32(container_length as u32)); + } else { + header.extend_from_slice(&pack_u32(0xFFFFFFFF)); + } + header.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); + header.extend_from_slice(&pack_u16(operation.into())); + header.extend_from_slice(&pack_u32(tx_id)); + + if self.split_header_data.load(Ordering::Relaxed) { + // Split mode: send the header as its own bulk transfer, then send + // each streamed chunk as its own bulk transfer. Required by some + // devices that don't handle a combined header+data bulk transfer. + self.transport.send_bulk(&header).await?; + while let Some(chunk_result) = data.next().await { + let chunk = chunk_result.map_err(Error::Io)?; + if !chunk.is_empty() { + self.transport.send_bulk(&chunk).await?; + } + } + } else { + // Combined mode: stream header + data as one continuous USB + // transfer. The transport handles buffering and ZLP termination, + // so we never buffer the entire file in RAM. + let header_stream = futures::stream::once(async { Ok(Bytes::from(header)) }); + let combined = header_stream.chain(data); + self.transport + .send_bulk_streaming(Box::pin(combined)) + .await?; + } + + // Receive response + let response_bytes = self.transport.receive_bulk(512).await?; + let response = ResponseContainer::from_bytes(&response_bytes)?; + + if response.transaction_id != tx_id { + return Err(Error::invalid_data(format!( + "Transaction ID mismatch: expected {}, got {}", + tx_id, response.transaction_id + ))); + } + + Ok(response) + } + + /// Download an object as a stream of chunks. + /// + /// This is a convenience method that calls `execute_with_receive_stream` + /// with GetObject operation. + /// + /// # Important + /// + /// The caller must either consume the entire stream or call + /// [`cancel()`](ReceiveStream::cancel) before dropping it. The MTP + /// session is locked while the stream is active. + pub async fn get_object_stream( + self: &Arc, + handle: ObjectHandle, + ) -> Result { + self.execute_with_receive_stream(OperationCode::GetObject, &[handle.0]) + .await + } + + /// Upload an object from a stream. + /// + /// This is a convenience method that streams object data directly to USB. + /// + /// # Arguments + /// + /// * `total_size` - Total bytes that will be sent + /// * `data` - Stream of data chunks to send + pub async fn send_object_stream(&self, total_size: u64, data: S) -> Result<(), Error> + where + S: Stream> + Unpin + Send, + { + let response = self + .execute_with_send_stream(OperationCode::SendObject, &[], total_size, data) + .await?; + Self::check_response(&response, OperationCode::SendObject)?; + Ok(()) + } +} + +/// A stream of data chunks received from USB during a download operation. +/// +/// This stream yields `Bytes` chunks as they arrive from the device, +/// allowing memory-efficient streaming without buffering the entire file. +/// +/// # Important +/// +/// The MTP session is locked while this stream exists. You must either +/// consume the entire stream or call [`cancel()`](Self::cancel) before +/// dropping it. Dropping mid-stream without cancelling corrupts the USB +/// session (a `debug_assert` catches this in debug builds). +#[must_use = "dropping a ReceiveStream mid-transfer corrupts the USB session; \ + consume it fully or call cancel()"] +pub struct ReceiveStream { + /// The transport layer for USB communication. + transport: Arc, + /// Guard that holds the operation lock for the duration of streaming. + _guard: OwnedMutexGuard<()>, + /// Transaction ID for this operation. + transaction_id: u32, + /// Operation code for this operation. + operation: OperationCode, + /// Buffer for partial container data. + buffer: Vec, + /// Total length of current container (from header). + container_length: usize, + /// How much payload we've already yielded from current container. + payload_yielded: usize, + /// Whether we've parsed the container header. + header_parsed: bool, + /// Whether the stream is complete. + done: bool, +} + +impl ReceiveStream { + /// Get the transaction ID for this operation. + #[must_use] + pub fn transaction_id(&self) -> u32 { + self.transaction_id + } + + /// Poll for the next chunk of data. + /// + /// This is the async version of the Stream trait's poll_next. + pub async fn next_chunk(&mut self) -> Option> { + if self.done { + return None; + } + + loop { + // If we have buffered data beyond what we've already yielded, yield it + if self.header_parsed { + let payload_start = HEADER_SIZE + self.payload_yielded; + let payload_end = std::cmp::min(self.buffer.len(), self.container_length); + + if payload_start < payload_end { + // We have new data to yield + let chunk_data = self.buffer[payload_start..payload_end].to_vec(); + self.payload_yielded += chunk_data.len(); + + // Check if this container is complete + if self.buffer.len() >= self.container_length { + // Remove this container from buffer + self.buffer.drain(..self.container_length); + self.header_parsed = false; + self.container_length = 0; + self.payload_yielded = 0; + } + + if !chunk_data.is_empty() { + return Some(Ok(Bytes::from(chunk_data))); + } + } else if self.buffer.len() >= self.container_length { + // Container complete but no new data (shouldn't happen, but handle it) + self.buffer.drain(..self.container_length); + self.header_parsed = false; + self.container_length = 0; + self.payload_yielded = 0; + } + } + + // Need more data from USB + match self.transport.receive_bulk(64 * 1024).await { + Ok(bytes) => { + if bytes.is_empty() { + return Some(Err(Error::invalid_data("Empty response from device"))); + } + self.buffer.extend_from_slice(&bytes); + } + Err(e) => { + self.done = true; + return Some(Err(e)); + } + } + + // Try to parse container header if we haven't yet + if !self.header_parsed && self.buffer.len() >= HEADER_SIZE { + let ct = match container_type(&self.buffer) { + Ok(ct) => ct, + Err(e) => { + self.done = true; + return Some(Err(e)); + } + }; + + match ct { + ContainerType::Data => { + let length = match unpack_u32(&self.buffer[0..4]) { + Ok(l) => l as usize, + Err(e) => { + self.done = true; + return Some(Err(e)); + } + }; + self.container_length = length; + self.header_parsed = true; + } + ContainerType::Response => { + // End of data transfer + let response = match ResponseContainer::from_bytes(&self.buffer) { + Ok(r) => r, + Err(e) => { + self.done = true; + return Some(Err(e)); + } + }; + + self.done = true; + + // Check transaction ID + if response.transaction_id != self.transaction_id { + return Some(Err(Error::invalid_data(format!( + "Transaction ID mismatch: expected {}, got {}", + self.transaction_id, response.transaction_id + )))); + } + + // Check response code + if response.code != ResponseCode::Ok { + return Some(Err(Error::Protocol { + code: response.code, + operation: self.operation, + })); + } + + return None; + } + _ => { + self.done = true; + return Some(Err(Error::invalid_data(format!( + "Unexpected container type: {:?}", + ct + )))); + } + } + } + } + } + + /// Cancel the in-progress download. + /// + /// Uses the USB Still Image Class cancel mechanism: sends a CLASS_CANCEL + /// control request to the device, then drains any remaining data from + /// the USB pipes. The session stays healthy for subsequent operations. + /// + /// The `idle_timeout` controls how long to wait during pipe drain before + /// assuming the pipe is clear. 300ms is the recommended default; see + /// [`DEFAULT_CANCEL_TIMEOUT`](crate::mtp::DEFAULT_CANCEL_TIMEOUT). + /// + /// If the stream is already complete, this is a no-op. + pub async fn cancel(&mut self, idle_timeout: Duration) -> Result<(), Error> { + if self.done { + return Ok(()); + } + self.done = true; + self.transport + .cancel_transfer(self.transaction_id, idle_timeout) + .await + } + + /// Collect all remaining data into a `Vec`. + /// + /// This consumes the stream and buffers all data in memory. + pub async fn collect(mut self) -> Result, Error> { + let mut data = Vec::new(); + while let Some(result) = self.next_chunk().await { + let chunk = result?; + data.extend_from_slice(&chunk); + } + Ok(data) + } +} + +impl Drop for ReceiveStream { + fn drop(&mut self) { + debug_assert!( + self.done, + "ReceiveStream dropped without consuming all data or calling cancel(). \ + This corrupts the USB session. Call cancel() before dropping, \ + or consume the stream to completion." + ); + } +} + +/// Convert a ReceiveStream into a futures::Stream using async iteration. +/// +/// This creates a proper Stream that can be used with StreamExt methods. +pub fn receive_stream_to_stream(recv: ReceiveStream) -> impl Stream> { + futures::stream::unfold(recv, |mut recv| async move { + recv.next_chunk().await.map(|result| (result, recv)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::session::tests::{ + data_container, mock_transport, ok_response, response_with_params, + }; + use crate::ptp::ResponseCode; + + #[tokio::test] + async fn test_receive_stream_small_file() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // GetObject data response (small file fits in one container) + let file_data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + mock.queue_response(ok_response(1)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + + // Use streaming API + let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Collect all chunks + let mut received = Vec::new(); + while let Some(result) = stream.next_chunk().await { + let chunk = result.unwrap(); + received.extend_from_slice(&chunk); + } + + assert_eq!(received, file_data); + } + + #[tokio::test] + async fn test_receive_stream_collect() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3, 4, 5]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + mock.queue_response(ok_response(1)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + + let stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + let collected = stream.collect().await.unwrap(); + + assert_eq!(collected, file_data); + } + + #[tokio::test] + async fn test_receive_stream_error_response() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + // Return error response instead of data + mock.queue_response(response_with_params( + 2, + ResponseCode::InvalidObjectHandle, + &[], + )); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + + let mut stream = session.get_object_stream(ObjectHandle(999)).await.unwrap(); + + // Should get error when reading + let result = stream.next_chunk().await; + assert!(result.is_some()); + let err = result.unwrap(); + assert!(err.is_err()); + } + + #[tokio::test] + async fn test_send_stream_small_file() { + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SendObject response + + let session = PtpSession::open(transport, 1).await.unwrap(); + + // Create a small data stream (use iter instead of once for Unpin) + let data = vec![1u8, 2, 3, 4, 5]; + let data_stream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(data.clone()))]); + + // Send using streaming API + session.send_object_stream(5, data_stream).await.unwrap(); + } + + #[tokio::test] + async fn test_send_stream_multiple_chunks() { + use futures::stream; + + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + mock.queue_response(ok_response(1)); // SendObject response + + let session = PtpSession::open(transport, 1).await.unwrap(); + + // Create a multi-chunk data stream + let chunks = vec![ + Ok::<_, std::io::Error>(Bytes::from(vec![1, 2, 3])), + Ok(Bytes::from(vec![4, 5, 6])), + Ok(Bytes::from(vec![7, 8, 9, 10])), + ]; + let data_stream = stream::iter(chunks); + + // Send using streaming API (total size = 10) + session.send_object_stream(10, data_stream).await.unwrap(); + } + + #[tokio::test] + async fn test_receive_stream_to_stream_conversion() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3, 4, 5]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + mock.queue_response(ok_response(1)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + + let recv_stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Convert to futures::Stream and use StreamExt + // Use pin_mut! to make it Unpin + use futures::StreamExt; + use std::pin::pin; + let mut stream = pin!(receive_stream_to_stream(recv_stream)); + + let mut collected = Vec::new(); + while let Some(result) = stream.next().await { + collected.extend_from_slice(&result.unwrap()); + } + + assert_eq!(collected, file_data); + } + + #[tokio::test] + async fn test_cancel_already_done() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + mock.queue_response(ok_response(1)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Consume the entire stream + while let Some(result) = stream.next_chunk().await { + result.unwrap(); + } + + // Cancel on a completed stream is a no-op + stream.cancel(Duration::from_secs(2)).await.unwrap(); + + // cancel_transfer should NOT have been called (stream was already done) + assert!(mock.get_cancel_calls().is_empty()); + } + + #[tokio::test] + async fn test_cancel_calls_transport_cancel_transfer() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3, 4, 5]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Read one chunk + stream.next_chunk().await.unwrap().unwrap(); + + // Cancel mid-stream — should delegate to transport.cancel_transfer() + stream.cancel(Duration::from_secs(2)).await.unwrap(); + + // Verify cancel_transfer was called with the correct transaction ID + let cancel_calls = mock.get_cancel_calls(); + assert_eq!(cancel_calls, vec![1]); // tx_id=1 (first operation after OpenSession) + } + + #[tokio::test] + async fn test_cancel_propagates_transport_error() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + + // Queue a cancel failure + mock.queue_cancel_result(Err(crate::Error::Disconnected)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Read one chunk + stream.next_chunk().await.unwrap().unwrap(); + + // Cancel should propagate the transport error + let result = stream.cancel(Duration::from_secs(2)).await; + assert!(result.is_err()); + + // Stream should be marked done even on error + assert!(stream.done); + } + + #[tokio::test] + async fn test_cancel_marks_stream_done() { + let (transport, mock) = mock_transport(); + mock.queue_response(ok_response(0)); // OpenSession + + let file_data = vec![1, 2, 3]; + mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); + + let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); + let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); + + // Read one chunk + stream.next_chunk().await.unwrap().unwrap(); + + // Cancel + stream.cancel(Duration::from_secs(2)).await.unwrap(); + + // Stream should be done — next_chunk returns None + assert!(stream.next_chunk().await.is_none()); + + // Second cancel is a no-op (no additional cancel_transfer call) + stream.cancel(Duration::from_secs(2)).await.unwrap(); + assert_eq!(mock.get_cancel_calls().len(), 1); + } +} diff --git a/vendor/mtp-rs/src/ptp/test_utils.rs b/vendor/mtp-rs/src/ptp/test_utils.rs new file mode 100644 index 0000000..581a575 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/test_utils.rs @@ -0,0 +1,82 @@ +//! Test utilities and macros for PTP module tests. +//! +//! This module provides macros for common test patterns, reducing boilerplate +//! across the codebase. + +/// Generate a proptest that verifies `Type::from_bytes` doesn't panic on arbitrary input. +/// +/// This is the most common fuzz test pattern: feed random bytes to a parser and +/// verify it handles them gracefully (returns Ok or Err, but never panics). +/// +/// # Usage +/// +/// ```ignore +/// fuzz_bytes!(data_container, DataContainer, 100); +/// ``` +/// +/// Expands to a proptest named `fuzz_data_container` that feeds 0-100 random bytes +/// to `DataContainer::from_bytes`. +#[cfg(test)] +#[macro_export] +macro_rules! fuzz_bytes { + ($test_name:ident, $type:ty, $max_size:expr) => { + proptest::proptest! { + #[test] + fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { + let _ = <$type>::from_bytes(&bytes); + } + } + }; +} + +/// Generate a proptest that verifies a function doesn't panic on arbitrary byte input. +/// +/// Similar to `fuzz_bytes!` but for standalone functions instead of methods. +/// +/// # Usage +/// +/// ```ignore +/// fuzz_bytes_fn!(fuzz_container_type, container_type, 100); +/// ``` +#[cfg(test)] +#[macro_export] +macro_rules! fuzz_bytes_fn { + ($test_name:ident, $fn_name:ident, $max_size:expr) => { + proptest::proptest! { + #[test] + fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { + let _ = $fn_name(&bytes); + } + } + }; +} + +/// Generate proptest for types with two-argument `from_bytes(bytes, type_hint)`. +/// +/// Used for parsers that need a type discriminator, like PropertyValue or PropertyRange. +/// +/// # Usage +/// +/// ```ignore +/// fuzz_bytes_with_type!( +/// fuzz_property_value, +/// PropertyValue, +/// PropertyDataType, +/// [Int8, Uint8, Int16, Uint16, Int32, Uint32, Int64, Uint64, String], +/// 20 +/// ); +/// ``` +#[cfg(test)] +#[macro_export] +macro_rules! fuzz_bytes_with_type { + ($test_name:ident, $type:ty, $hint_type:ty, [$($variant:ident),+ $(,)?], $max_size:expr) => { + proptest::proptest! { + #[test] + fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { + $( + let _ = <$type>::from_bytes(&bytes, <$hint_type>::$variant); + )+ + } + } + }; +} diff --git a/vendor/mtp-rs/src/ptp/types/device.rs b/vendor/mtp-rs/src/ptp/types/device.rs new file mode 100644 index 0000000..e3cc184 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/types/device.rs @@ -0,0 +1,513 @@ +//! Device and storage information types for MTP/PTP. +//! +//! This module contains: +//! - [`DeviceInfo`]: Device capabilities and identification +//! - [`StorageInfo`]: Storage characteristics and capacity + +use super::storage::{AccessCapability, FilesystemType, StorageType}; +use crate::ptp::pack::{unpack_string, unpack_u16, unpack_u16_array, unpack_u32, unpack_u64}; +use crate::ptp::{EventCode, ObjectFormatCode, OperationCode}; + +// --- DeviceInfo Structure --- + +/// Device information returned by GetDeviceInfo. +/// +/// Contains device capabilities, manufacturer info, and supported operations. +#[derive(Debug, Clone, Default)] +pub struct DeviceInfo { + /// PTP standard version (e.g., 100 = v1.00). + pub standard_version: u16, + /// Vendor extension ID (0 = no extension). + pub vendor_extension_id: u32, + /// Vendor extension version. + pub vendor_extension_version: u16, + /// Vendor extension description. + pub vendor_extension_desc: String, + /// Functional mode (0 = standard). + pub functional_mode: u16, + /// Operations supported by the device. + pub operations_supported: Vec, + /// Events supported by the device. + pub events_supported: Vec, + /// Device properties supported. + pub device_properties_supported: Vec, + /// Object formats the device can capture/create. + pub capture_formats: Vec, + /// Object formats the device can play/display. + pub playback_formats: Vec, + /// Manufacturer name. + pub manufacturer: String, + /// Device model name. + pub model: String, + /// Device version string. + pub device_version: String, + /// Device serial number. + pub serial_number: String, +} + +impl DeviceInfo { + /// Parse DeviceInfo from a byte buffer. + /// + /// The buffer should contain the DeviceInfo dataset as returned by GetDeviceInfo. + pub fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; + + // 1. StandardVersion (u16) + let standard_version = unpack_u16(&buf[offset..])?; + offset += 2; + + // 2. VendorExtensionID (u32) + let vendor_extension_id = unpack_u32(&buf[offset..])?; + offset += 4; + + // 3. VendorExtensionVersion (u16) + let vendor_extension_version = unpack_u16(&buf[offset..])?; + offset += 2; + + // 4. VendorExtensionDesc (string) + let (vendor_extension_desc, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 5. FunctionalMode (u16) + let functional_mode = unpack_u16(&buf[offset..])?; + offset += 2; + + // 6. OperationsSupported (u16 array) + let (ops_raw, consumed) = unpack_u16_array(&buf[offset..])?; + let operations_supported: Vec = + ops_raw.into_iter().map(OperationCode::from).collect(); + offset += consumed; + + // 7. EventsSupported (u16 array) + let (events_raw, consumed) = unpack_u16_array(&buf[offset..])?; + let events_supported: Vec = + events_raw.into_iter().map(EventCode::from).collect(); + offset += consumed; + + // 8. DevicePropertiesSupported (u16 array) + let (device_properties_supported, consumed) = unpack_u16_array(&buf[offset..])?; + offset += consumed; + + // 9. CaptureFormats (u16 array) + let (capture_raw, consumed) = unpack_u16_array(&buf[offset..])?; + let capture_formats: Vec = capture_raw + .into_iter() + .map(ObjectFormatCode::from) + .collect(); + offset += consumed; + + // 10. PlaybackFormats (u16 array) + let (playback_raw, consumed) = unpack_u16_array(&buf[offset..])?; + let playback_formats: Vec = playback_raw + .into_iter() + .map(ObjectFormatCode::from) + .collect(); + offset += consumed; + + // 11. Manufacturer (string) + let (manufacturer, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 12. Model (string) + let (model, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 13. DeviceVersion (string) + let (device_version, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 14. SerialNumber (string) + let (serial_number, _) = unpack_string(&buf[offset..])?; + + Ok(DeviceInfo { + standard_version, + vendor_extension_id, + vendor_extension_version, + vendor_extension_desc, + functional_mode, + operations_supported, + events_supported, + device_properties_supported, + capture_formats, + playback_formats, + manufacturer, + model, + device_version, + serial_number, + }) + } + + /// Check if the device supports a specific operation. + /// + /// # Arguments + /// + /// * `operation` - The operation code to check + /// + /// # Returns + /// + /// Returns true if the operation is in the device's supported operations list. + #[must_use] + pub fn supports_operation(&self, operation: OperationCode) -> bool { + self.operations_supported.contains(&operation) + } + + /// Check if the device supports renaming objects. + /// + /// This checks for support of the SetObjectPropValue operation (0x9804), + /// which is required to rename files and folders via the ObjectFileName property. + /// + /// # Returns + /// + /// Returns true if the device advertises SetObjectPropValue support. + #[must_use] + pub fn supports_rename(&self) -> bool { + self.supports_operation(OperationCode::SetObjectPropValue) + } +} + +// --- StorageInfo Structure --- + +/// Storage information returned by GetStorageInfo. +/// +/// Contains storage capacity, type, and access information. +#[derive(Debug, Clone, Default)] +pub struct StorageInfo { + /// Type of storage medium. + pub storage_type: StorageType, + /// Type of filesystem. + pub filesystem_type: FilesystemType, + /// Access capability. + pub access_capability: AccessCapability, + /// Maximum storage capacity in bytes. + pub max_capacity: u64, + /// Free space in bytes. + pub free_space_bytes: u64, + /// Free space in number of objects (0xFFFFFFFF if unknown). + pub free_space_objects: u32, + /// Storage description string. + pub description: String, + /// Volume identifier/label. + pub volume_identifier: String, +} + +impl StorageInfo { + /// Parse StorageInfo from a byte buffer. + /// + /// The buffer should contain the StorageInfo dataset as returned by GetStorageInfo. + pub fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; + + // 1. StorageType (u16) + let storage_type = StorageType::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 2. FilesystemType (u16) + let filesystem_type = FilesystemType::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 3. AccessCapability (u16) + let access_capability = AccessCapability::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 4. MaxCapacity (u64) + let max_capacity = unpack_u64(&buf[offset..])?; + offset += 8; + + // 5. FreeSpaceInBytes (u64) + let free_space_bytes = unpack_u64(&buf[offset..])?; + offset += 8; + + // 6. FreeSpaceInObjects (u32) + let free_space_objects = unpack_u32(&buf[offset..])?; + offset += 4; + + // 7. StorageDescription (string) + let (description, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 8. VolumeIdentifier (string) + let (volume_identifier, _) = unpack_string(&buf[offset..])?; + + Ok(StorageInfo { + storage_type, + filesystem_type, + access_capability, + max_capacity, + free_space_bytes, + free_space_objects, + description, + volume_identifier, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::pack::{pack_string, pack_u16, pack_u16_array, pack_u32}; + + // --- DeviceInfo Tests --- + + fn build_minimal_device_info_bytes() -> Vec { + let mut buf = Vec::new(); + + // StandardVersion: 100 (v1.00) + buf.extend_from_slice(&pack_u16(100)); + // VendorExtensionID: 0 + buf.extend_from_slice(&pack_u32(0)); + // VendorExtensionVersion: 0 + buf.extend_from_slice(&pack_u16(0)); + // VendorExtensionDesc: empty string + buf.push(0x00); + // FunctionalMode: 0 + buf.extend_from_slice(&pack_u16(0)); + // OperationsSupported: empty array + buf.extend_from_slice(&pack_u16_array(&[])); + // EventsSupported: empty array + buf.extend_from_slice(&pack_u16_array(&[])); + // DevicePropertiesSupported: empty array + buf.extend_from_slice(&pack_u16_array(&[])); + // CaptureFormats: empty array + buf.extend_from_slice(&pack_u16_array(&[])); + // PlaybackFormats: empty array + buf.extend_from_slice(&pack_u16_array(&[])); + // Manufacturer: empty string + buf.push(0x00); + // Model: empty string + buf.push(0x00); + // DeviceVersion: empty string + buf.push(0x00); + // SerialNumber: empty string + buf.push(0x00); + + buf + } + + #[test] + fn device_info_parse_minimal() { + let buf = build_minimal_device_info_bytes(); + let info = DeviceInfo::from_bytes(&buf).unwrap(); + + assert_eq!(info.standard_version, 100); + assert_eq!(info.vendor_extension_id, 0); + assert_eq!(info.vendor_extension_version, 0); + assert_eq!(info.vendor_extension_desc, ""); + assert_eq!(info.functional_mode, 0); + assert!(info.operations_supported.is_empty()); + assert!(info.events_supported.is_empty()); + assert!(info.device_properties_supported.is_empty()); + assert!(info.capture_formats.is_empty()); + assert!(info.playback_formats.is_empty()); + assert_eq!(info.manufacturer, ""); + assert_eq!(info.model, ""); + assert_eq!(info.device_version, ""); + assert_eq!(info.serial_number, ""); + } + + fn build_full_device_info_bytes() -> Vec { + let mut buf = Vec::new(); + + // StandardVersion: 100 (v1.00) + buf.extend_from_slice(&pack_u16(100)); + // VendorExtensionID: 0x00000006 (Microsoft) + buf.extend_from_slice(&pack_u32(6)); + // VendorExtensionVersion: 100 + buf.extend_from_slice(&pack_u16(100)); + // VendorExtensionDesc: "microsoft.com: 1.0" + buf.extend_from_slice(&pack_string("microsoft.com: 1.0")); + // FunctionalMode: 0 + buf.extend_from_slice(&pack_u16(0)); + // OperationsSupported: [GetDeviceInfo, OpenSession, CloseSession] + buf.extend_from_slice(&pack_u16_array(&[0x1001, 0x1002, 0x1003])); + // EventsSupported: [ObjectAdded, ObjectRemoved] + buf.extend_from_slice(&pack_u16_array(&[0x4002, 0x4003])); + // DevicePropertiesSupported: [0x5001, 0x5002] + buf.extend_from_slice(&pack_u16_array(&[0x5001, 0x5002])); + // CaptureFormats: [JPEG] + buf.extend_from_slice(&pack_u16_array(&[0x3801])); + // PlaybackFormats: [JPEG, MP3] + buf.extend_from_slice(&pack_u16_array(&[0x3801, 0x3009])); + // Manufacturer: "Test Manufacturer" + buf.extend_from_slice(&pack_string("Test Manufacturer")); + // Model: "Test Model" + buf.extend_from_slice(&pack_string("Test Model")); + // DeviceVersion: "1.0.0" + buf.extend_from_slice(&pack_string("1.0.0")); + // SerialNumber: "ABC123" + buf.extend_from_slice(&pack_string("ABC123")); + + buf + } + + #[test] + fn device_info_parse_full() { + let buf = build_full_device_info_bytes(); + let info = DeviceInfo::from_bytes(&buf).unwrap(); + + assert_eq!(info.standard_version, 100); + assert_eq!(info.vendor_extension_id, 6); + assert_eq!(info.vendor_extension_version, 100); + assert_eq!(info.vendor_extension_desc, "microsoft.com: 1.0"); + assert_eq!(info.functional_mode, 0); + + assert_eq!(info.operations_supported.len(), 3); + assert_eq!(info.operations_supported[0], OperationCode::GetDeviceInfo); + assert_eq!(info.operations_supported[1], OperationCode::OpenSession); + assert_eq!(info.operations_supported[2], OperationCode::CloseSession); + + assert_eq!(info.events_supported.len(), 2); + assert_eq!(info.events_supported[0], EventCode::ObjectAdded); + assert_eq!(info.events_supported[1], EventCode::ObjectRemoved); + + assert_eq!(info.device_properties_supported, vec![0x5001, 0x5002]); + + assert_eq!(info.capture_formats.len(), 1); + assert_eq!(info.capture_formats[0], ObjectFormatCode::Jpeg); + + assert_eq!(info.playback_formats.len(), 2); + assert_eq!(info.playback_formats[0], ObjectFormatCode::Jpeg); + assert_eq!(info.playback_formats[1], ObjectFormatCode::Mp3); + + assert_eq!(info.manufacturer, "Test Manufacturer"); + assert_eq!(info.model, "Test Model"); + assert_eq!(info.device_version, "1.0.0"); + assert_eq!(info.serial_number, "ABC123"); + } + + #[test] + fn device_info_parse_insufficient_bytes() { + let buf = vec![0x00, 0x01]; // Only 2 bytes + assert!(DeviceInfo::from_bytes(&buf).is_err()); + } + + // --- StorageInfo Tests --- + + fn build_storage_info_bytes() -> Vec { + let mut buf = Vec::new(); + + // StorageType: RemovableRam (4) + buf.extend_from_slice(&pack_u16(4)); + // FilesystemType: GenericHierarchical (2) + buf.extend_from_slice(&pack_u16(2)); + // AccessCapability: ReadWrite (0) + buf.extend_from_slice(&pack_u16(0)); + // MaxCapacity: 32GB + buf.extend_from_slice(&32_000_000_000u64.to_le_bytes()); + // FreeSpaceInBytes: 16GB + buf.extend_from_slice(&16_000_000_000u64.to_le_bytes()); + // FreeSpaceInObjects: 0xFFFFFFFF (unknown) + buf.extend_from_slice(&pack_u32(0xFFFFFFFF)); + // StorageDescription: "SD Card" + buf.extend_from_slice(&pack_string("SD Card")); + // VolumeIdentifier: "VOL001" + buf.extend_from_slice(&pack_string("VOL001")); + + buf + } + + #[test] + fn storage_info_parse() { + let buf = build_storage_info_bytes(); + let info = StorageInfo::from_bytes(&buf).unwrap(); + + assert_eq!(info.storage_type, StorageType::RemovableRam); + assert_eq!(info.filesystem_type, FilesystemType::GenericHierarchical); + assert_eq!(info.access_capability, AccessCapability::ReadWrite); + assert_eq!(info.max_capacity, 32_000_000_000); + assert_eq!(info.free_space_bytes, 16_000_000_000); + assert_eq!(info.free_space_objects, 0xFFFFFFFF); + assert_eq!(info.description, "SD Card"); + assert_eq!(info.volume_identifier, "VOL001"); + } + + #[test] + fn storage_info_parse_insufficient_bytes() { + let buf = vec![0x00; 10]; // Not enough bytes + assert!(StorageInfo::from_bytes(&buf).is_err()); + } + + // --- DeviceInfo capability tests --- + + #[test] + fn device_info_supports_operation() { + let info = DeviceInfo { + operations_supported: vec![ + OperationCode::GetDeviceInfo, + OperationCode::OpenSession, + OperationCode::SetObjectPropValue, + ], + ..Default::default() + }; + + assert!(info.supports_operation(OperationCode::GetDeviceInfo)); + assert!(info.supports_operation(OperationCode::OpenSession)); + assert!(info.supports_operation(OperationCode::SetObjectPropValue)); + assert!(!info.supports_operation(OperationCode::DeleteObject)); + assert!(!info.supports_operation(OperationCode::GetObjectPropValue)); + } + + #[test] + fn device_info_supports_rename_true() { + let info = DeviceInfo { + operations_supported: vec![ + OperationCode::GetDeviceInfo, + OperationCode::SetObjectPropValue, // Required for rename + ], + ..Default::default() + }; + + assert!(info.supports_rename()); + } + + #[test] + fn device_info_supports_rename_false() { + let info = DeviceInfo { + operations_supported: vec![ + OperationCode::GetDeviceInfo, + OperationCode::GetObjectPropValue, // Has Get but not Set + ], + ..Default::default() + }; + + assert!(!info.supports_rename()); + } + + #[test] + fn device_info_supports_rename_empty() { + let info = DeviceInfo::default(); + assert!(!info.supports_rename()); + } + + // Fuzz tests using shared macros - verify parsers don't panic on arbitrary input + crate::fuzz_bytes!(fuzz_device_info, DeviceInfo, 200); + crate::fuzz_bytes!(fuzz_storage_info, StorageInfo, 100); + + #[test] + fn device_info_minimum_valid() { + // DeviceInfo needs at minimum: u16 + u32 + u16 + string + u16 + 5 arrays + 4 strings + // This is a lot of bytes. Test that small buffers fail gracefully. + assert!(DeviceInfo::from_bytes(&[]).is_err()); + assert!(DeviceInfo::from_bytes(&[0; 1]).is_err()); + assert!(DeviceInfo::from_bytes(&[0; 7]).is_err()); + assert!(DeviceInfo::from_bytes(&[0; 8]).is_err()); // Need at least string data after first fields + } + + #[test] + fn storage_info_minimum_valid() { + // StorageInfo needs: 3 * u16 + 2 * u64 + u32 + 2 strings = 26 bytes minimum + string data + assert!(StorageInfo::from_bytes(&[]).is_err()); + assert!(StorageInfo::from_bytes(&[0; 25]).is_err()); + assert!(StorageInfo::from_bytes(&[0; 26]).is_err()); // Still need string data + } + + #[test] + fn storage_info_max_capacity() { + let mut buf = build_storage_info_bytes(); + // Replace MaxCapacity field (bytes 6-13, after 3 u16s = 6 bytes) + let max_bytes = u64::MAX.to_le_bytes(); + buf[6..14].copy_from_slice(&max_bytes); + + let info = StorageInfo::from_bytes(&buf).unwrap(); + assert_eq!(info.max_capacity, u64::MAX); + } +} diff --git a/vendor/mtp-rs/src/ptp/types/mod.rs b/vendor/mtp-rs/src/ptp/types/mod.rs new file mode 100644 index 0000000..9ff926c --- /dev/null +++ b/vendor/mtp-rs/src/ptp/types/mod.rs @@ -0,0 +1,21 @@ +//! MTP/PTP data structures for device, storage, and object information. +//! +//! This module provides high-level structures for parsing protocol responses: +//! - [`DeviceInfo`]: Device capabilities and identification +//! - [`StorageInfo`]: Storage characteristics and capacity +//! - [`ObjectInfo`]: File/folder metadata +//! - [`DevicePropDesc`]: Device property descriptors +//! - [`PropertyValue`]: Property values of various types + +mod device; +mod objects; +mod properties; +mod storage; + +// Re-export all public types for backward compatibility +pub use device::{DeviceInfo, StorageInfo}; +pub use objects::ObjectInfo; +pub use properties::{DevicePropDesc, PropertyFormType, PropertyRange, PropertyValue}; +pub use storage::{ + AccessCapability, AssociationType, FilesystemType, ProtectionStatus, StorageType, +}; diff --git a/vendor/mtp-rs/src/ptp/types/objects.rs b/vendor/mtp-rs/src/ptp/types/objects.rs new file mode 100644 index 0000000..21f57c3 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/types/objects.rs @@ -0,0 +1,595 @@ +//! Object-related types for MTP/PTP. +//! +//! This module contains the [`ObjectInfo`] structure for file/folder metadata. + +use super::storage::{AssociationType, ProtectionStatus}; +use crate::ptp::pack::{ + pack_datetime, pack_string, pack_u16, pack_u32, unpack_datetime, unpack_string, unpack_u16, + unpack_u32, DateTime, +}; +use crate::ptp::{ObjectFormatCode, ObjectHandle, StorageId}; + +// --- ObjectInfo Structure --- + +/// Object information returned by GetObjectInfo. +/// +/// Contains file/folder metadata including name, size, timestamps, and hierarchy info. +#[derive(Debug, Clone, Default)] +pub struct ObjectInfo { + /// Object handle (set after parsing, not part of protocol data). + pub handle: ObjectHandle, + /// Storage containing this object. + pub storage_id: StorageId, + /// Object format code. + pub format: ObjectFormatCode, + /// Protection status. + pub protection_status: ProtectionStatus, + /// Object size in bytes. + /// + /// The standard `ObjectInfo` dataset encodes size as u32 which saturates at + /// `u32::MAX` for files larger than 4 GB. When this happens and the dataset + /// is obtained via high-level APIs like [`Storage::get_object_info`], + /// [`Storage::list_objects`], or [`PtpSession::get_object_info_full`], the + /// real u64 size is auto-resolved via `GetObjectPropValue(ObjectSize)`. + /// + /// [`Storage::get_object_info`]: crate::mtp::Storage::get_object_info + /// [`Storage::list_objects`]: crate::mtp::Storage::list_objects + /// [`PtpSession::get_object_info_full`]: crate::ptp::PtpSession::get_object_info_full + pub size: u64, + /// Thumbnail format. + pub thumb_format: ObjectFormatCode, + /// Thumbnail size in bytes. + pub thumb_size: u32, + /// Thumbnail width in pixels. + pub thumb_width: u32, + /// Thumbnail height in pixels. + pub thumb_height: u32, + /// Image width in pixels. + pub image_width: u32, + /// Image height in pixels. + pub image_height: u32, + /// Image bit depth. + pub image_bit_depth: u32, + /// Parent object handle (ROOT for root-level objects). + pub parent: ObjectHandle, + /// Association type (folder type). + pub association_type: AssociationType, + /// Association description. + pub association_desc: u32, + /// Sequence number. + pub sequence_number: u32, + /// Filename. + pub filename: String, + /// Creation timestamp. + pub created: Option, + /// Modification timestamp. + pub modified: Option, + /// Keywords string. + pub keywords: String, +} + +impl ObjectInfo { + /// Parse ObjectInfo from a byte buffer. + /// + /// The buffer should contain the ObjectInfo dataset as returned by GetObjectInfo. + pub fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; + + // 1. StorageID (u32) + let storage_id = StorageId(unpack_u32(&buf[offset..])?); + offset += 4; + + // 2. ObjectFormat (u16) + let format = ObjectFormatCode::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 3. ProtectionStatus (u16) + let protection_status = ProtectionStatus::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 4. ObjectCompressedSize (u32) - stored as u64, but protocol uses u32 + let size = unpack_u32(&buf[offset..])? as u64; + offset += 4; + + // 5. ThumbFormat (u16) + let thumb_format = ObjectFormatCode::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 6. ThumbCompressedSize (u32) + let thumb_size = unpack_u32(&buf[offset..])?; + offset += 4; + + // 7. ThumbPixWidth (u32) + let thumb_width = unpack_u32(&buf[offset..])?; + offset += 4; + + // 8. ThumbPixHeight (u32) + let thumb_height = unpack_u32(&buf[offset..])?; + offset += 4; + + // 9. ImagePixWidth (u32) + let image_width = unpack_u32(&buf[offset..])?; + offset += 4; + + // 10. ImagePixHeight (u32) + let image_height = unpack_u32(&buf[offset..])?; + offset += 4; + + // 11. ImageBitDepth (u32) + let image_bit_depth = unpack_u32(&buf[offset..])?; + offset += 4; + + // 12. ParentObject (u32) + let parent = ObjectHandle(unpack_u32(&buf[offset..])?); + offset += 4; + + // 13. AssociationType (u16) + let association_type = AssociationType::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 14. AssociationDesc (u32) + let association_desc = unpack_u32(&buf[offset..])?; + offset += 4; + + // 15. SequenceNumber (u32) + let sequence_number = unpack_u32(&buf[offset..])?; + offset += 4; + + // 16. Filename (string) + let (filename, consumed) = unpack_string(&buf[offset..])?; + offset += consumed; + + // 17. DateCreated (datetime string) + let (created, consumed) = unpack_datetime(&buf[offset..])?; + offset += consumed; + + // 18. DateModified (datetime string) + let (modified, consumed) = unpack_datetime(&buf[offset..])?; + offset += consumed; + + // 19. Keywords (string) + let (keywords, _consumed) = unpack_string(&buf[offset..])?; + + Ok(ObjectInfo { + handle: ObjectHandle::default(), // Set by caller after parsing + storage_id, + format, + protection_status, + size, + thumb_format, + thumb_size, + thumb_width, + thumb_height, + image_width, + image_height, + image_bit_depth, + parent, + association_type, + association_desc, + sequence_number, + filename, + created, + modified, + keywords, + }) + } + + /// Serialize ObjectInfo to a byte buffer. + /// + /// Used for SendObjectInfo operation. + /// + /// Returns an error if the created or modified DateTime contains invalid values. + pub fn to_bytes(&self) -> Result, crate::Error> { + let mut buf = Vec::new(); + + // 1. StorageID (u32) + buf.extend_from_slice(&pack_u32(self.storage_id.0)); + + // 2. ObjectFormat (u16) + buf.extend_from_slice(&pack_u16(self.format.into())); + + // 3. ProtectionStatus (u16) + buf.extend_from_slice(&pack_u16(self.protection_status.into())); + + // 4. ObjectCompressedSize (u32) - cap at u32::MAX for >4GB files + let size_u32 = if self.size > u32::MAX as u64 { + u32::MAX + } else { + self.size as u32 + }; + buf.extend_from_slice(&pack_u32(size_u32)); + + // 5. ThumbFormat (u16) + buf.extend_from_slice(&pack_u16(self.thumb_format.into())); + + // 6. ThumbCompressedSize (u32) + buf.extend_from_slice(&pack_u32(self.thumb_size)); + + // 7. ThumbPixWidth (u32) + buf.extend_from_slice(&pack_u32(self.thumb_width)); + + // 8. ThumbPixHeight (u32) + buf.extend_from_slice(&pack_u32(self.thumb_height)); + + // 9. ImagePixWidth (u32) + buf.extend_from_slice(&pack_u32(self.image_width)); + + // 10. ImagePixHeight (u32) + buf.extend_from_slice(&pack_u32(self.image_height)); + + // 11. ImageBitDepth (u32) + buf.extend_from_slice(&pack_u32(self.image_bit_depth)); + + // 12. ParentObject (u32) + buf.extend_from_slice(&pack_u32(self.parent.0)); + + // 13. AssociationType (u16) + buf.extend_from_slice(&pack_u16(self.association_type.into())); + + // 14. AssociationDesc (u32) + buf.extend_from_slice(&pack_u32(self.association_desc)); + + // 15. SequenceNumber (u32) + buf.extend_from_slice(&pack_u32(self.sequence_number)); + + // 16. Filename (string) + buf.extend_from_slice(&pack_string(&self.filename)); + + // 17. DateCreated (datetime string) + if let Some(dt) = &self.created { + buf.extend_from_slice(&pack_datetime(dt)?); + } else { + buf.push(0x00); // Empty string + } + + // 18. DateModified (datetime string) + if let Some(dt) = &self.modified { + buf.extend_from_slice(&pack_datetime(dt)?); + } else { + buf.push(0x00); // Empty string + } + + // 19. Keywords (string) + buf.extend_from_slice(&pack_string(&self.keywords)); + + Ok(buf) + } + + /// Check if this object is a folder. + /// + /// Returns true if the format is Association or the association type is GenericFolder. + #[must_use] + pub fn is_folder(&self) -> bool { + self.format == ObjectFormatCode::Association + || self.association_type == AssociationType::GenericFolder + } + + /// Check if this object is a file. + /// + /// Returns true if this is not a folder. + #[must_use] + pub fn is_file(&self) -> bool { + !self.is_folder() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::pack::{pack_datetime, pack_string, pack_u16, pack_u32, DateTime}; + + // --- ObjectInfo Tests --- + + fn build_file_object_info_bytes() -> Vec { + let mut buf = Vec::new(); + + // StorageID: 0x00010001 + buf.extend_from_slice(&pack_u32(0x00010001)); + // ObjectFormat: JPEG (0x3801) + buf.extend_from_slice(&pack_u16(0x3801)); + // ProtectionStatus: None (0) + buf.extend_from_slice(&pack_u16(0)); + // ObjectCompressedSize: 1024 bytes + buf.extend_from_slice(&pack_u32(1024)); + // ThumbFormat: JPEG (0x3801) + buf.extend_from_slice(&pack_u16(0x3801)); + // ThumbCompressedSize: 512 + buf.extend_from_slice(&pack_u32(512)); + // ThumbPixWidth: 160 + buf.extend_from_slice(&pack_u32(160)); + // ThumbPixHeight: 120 + buf.extend_from_slice(&pack_u32(120)); + // ImagePixWidth: 1920 + buf.extend_from_slice(&pack_u32(1920)); + // ImagePixHeight: 1080 + buf.extend_from_slice(&pack_u32(1080)); + // ImageBitDepth: 24 + buf.extend_from_slice(&pack_u32(24)); + // ParentObject: 0x00000005 + buf.extend_from_slice(&pack_u32(5)); + // AssociationType: None (0) + buf.extend_from_slice(&pack_u16(0)); + // AssociationDesc: 0 + buf.extend_from_slice(&pack_u32(0)); + // SequenceNumber: 1 + buf.extend_from_slice(&pack_u32(1)); + // Filename: "photo.jpg" + buf.extend_from_slice(&pack_string("photo.jpg")); + // DateCreated: "20240315T143022" + buf.extend_from_slice( + &pack_datetime(&DateTime { + year: 2024, + month: 3, + day: 15, + hour: 14, + minute: 30, + second: 22, + }) + .unwrap(), + ); + // DateModified: "20240316T090000" + buf.extend_from_slice( + &pack_datetime(&DateTime { + year: 2024, + month: 3, + day: 16, + hour: 9, + minute: 0, + second: 0, + }) + .unwrap(), + ); + // Keywords: "" + buf.push(0x00); + + buf + } + + #[test] + fn object_info_parse_file() { + let buf = build_file_object_info_bytes(); + let info = ObjectInfo::from_bytes(&buf).unwrap(); + + assert_eq!(info.storage_id, StorageId(0x00010001)); + assert_eq!(info.format, ObjectFormatCode::Jpeg); + assert_eq!(info.protection_status, ProtectionStatus::None); + assert_eq!(info.size, 1024); + assert_eq!(info.thumb_format, ObjectFormatCode::Jpeg); + assert_eq!(info.thumb_size, 512); + assert_eq!(info.thumb_width, 160); + assert_eq!(info.thumb_height, 120); + assert_eq!(info.image_width, 1920); + assert_eq!(info.image_height, 1080); + assert_eq!(info.image_bit_depth, 24); + assert_eq!(info.parent, ObjectHandle(5)); + assert_eq!(info.association_type, AssociationType::None); + assert_eq!(info.association_desc, 0); + assert_eq!(info.sequence_number, 1); + assert_eq!(info.filename, "photo.jpg"); + assert!(info.created.is_some()); + let created = info.created.unwrap(); + assert_eq!(created.year, 2024); + assert_eq!(created.month, 3); + assert_eq!(created.day, 15); + assert!(info.modified.is_some()); + assert_eq!(info.keywords, ""); + + assert!(info.is_file()); + assert!(!info.is_folder()); + } + + fn build_folder_object_info_bytes() -> Vec { + let mut buf = Vec::new(); + + // StorageID: 0x00010001 + buf.extend_from_slice(&pack_u32(0x00010001)); + // ObjectFormat: Association (0x3001) + buf.extend_from_slice(&pack_u16(0x3001)); + // ProtectionStatus: None (0) + buf.extend_from_slice(&pack_u16(0)); + // ObjectCompressedSize: 0 + buf.extend_from_slice(&pack_u32(0)); + // ThumbFormat: Undefined (0x3000) + buf.extend_from_slice(&pack_u16(0x3000)); + // ThumbCompressedSize: 0 + buf.extend_from_slice(&pack_u32(0)); + // ThumbPixWidth: 0 + buf.extend_from_slice(&pack_u32(0)); + // ThumbPixHeight: 0 + buf.extend_from_slice(&pack_u32(0)); + // ImagePixWidth: 0 + buf.extend_from_slice(&pack_u32(0)); + // ImagePixHeight: 0 + buf.extend_from_slice(&pack_u32(0)); + // ImageBitDepth: 0 + buf.extend_from_slice(&pack_u32(0)); + // ParentObject: ROOT (0) + buf.extend_from_slice(&pack_u32(0)); + // AssociationType: GenericFolder (1) + buf.extend_from_slice(&pack_u16(1)); + // AssociationDesc: 0 + buf.extend_from_slice(&pack_u32(0)); + // SequenceNumber: 0 + buf.extend_from_slice(&pack_u32(0)); + // Filename: "DCIM" + buf.extend_from_slice(&pack_string("DCIM")); + // DateCreated: empty + buf.push(0x00); + // DateModified: empty + buf.push(0x00); + // Keywords: "" + buf.push(0x00); + + buf + } + + #[test] + fn object_info_parse_folder() { + let buf = build_folder_object_info_bytes(); + let info = ObjectInfo::from_bytes(&buf).unwrap(); + + assert_eq!(info.format, ObjectFormatCode::Association); + assert_eq!(info.association_type, AssociationType::GenericFolder); + assert_eq!(info.filename, "DCIM"); + assert_eq!(info.parent, ObjectHandle::ROOT); + assert!(info.created.is_none()); + assert!(info.modified.is_none()); + + assert!(info.is_folder()); + assert!(!info.is_file()); + } + + #[test] + fn object_info_to_bytes_roundtrip() { + let original = ObjectInfo { + handle: ObjectHandle(42), + storage_id: StorageId(0x00010001), + format: ObjectFormatCode::Jpeg, + protection_status: ProtectionStatus::None, + size: 2048, + thumb_format: ObjectFormatCode::Jpeg, + thumb_size: 256, + thumb_width: 80, + thumb_height: 60, + image_width: 800, + image_height: 600, + image_bit_depth: 24, + parent: ObjectHandle(10), + association_type: AssociationType::None, + association_desc: 0, + sequence_number: 5, + filename: "test.jpg".to_string(), + created: Some(DateTime { + year: 2024, + month: 6, + day: 15, + hour: 10, + minute: 30, + second: 0, + }), + modified: Some(DateTime { + year: 2024, + month: 6, + day: 16, + hour: 11, + minute: 45, + second: 30, + }), + keywords: "test,photo".to_string(), + }; + + let bytes = original.to_bytes().unwrap(); + let parsed = ObjectInfo::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.storage_id, original.storage_id); + assert_eq!(parsed.format, original.format); + assert_eq!(parsed.protection_status, original.protection_status); + assert_eq!(parsed.size, original.size); + assert_eq!(parsed.thumb_format, original.thumb_format); + assert_eq!(parsed.thumb_size, original.thumb_size); + assert_eq!(parsed.thumb_width, original.thumb_width); + assert_eq!(parsed.thumb_height, original.thumb_height); + assert_eq!(parsed.image_width, original.image_width); + assert_eq!(parsed.image_height, original.image_height); + assert_eq!(parsed.image_bit_depth, original.image_bit_depth); + assert_eq!(parsed.parent, original.parent); + assert_eq!(parsed.association_type, original.association_type); + assert_eq!(parsed.association_desc, original.association_desc); + assert_eq!(parsed.sequence_number, original.sequence_number); + assert_eq!(parsed.filename, original.filename); + assert_eq!(parsed.created, original.created); + assert_eq!(parsed.modified, original.modified); + assert_eq!(parsed.keywords, original.keywords); + } + + #[test] + fn object_info_to_bytes_large_size() { + let info = ObjectInfo { + size: 5_000_000_000, // 5GB, larger than u32::MAX + ..Default::default() + }; + + let bytes = info.to_bytes().unwrap(); + let parsed = ObjectInfo::from_bytes(&bytes).unwrap(); + + // Should be capped at u32::MAX when serializing + assert_eq!(parsed.size, u32::MAX as u64); + } + + #[test] + fn object_info_is_folder_by_format() { + let info = ObjectInfo { + format: ObjectFormatCode::Association, + association_type: AssociationType::None, + ..Default::default() + }; + assert!(info.is_folder()); + } + + #[test] + fn object_info_is_folder_by_association() { + let info = ObjectInfo { + format: ObjectFormatCode::Undefined, + association_type: AssociationType::GenericFolder, + ..Default::default() + }; + assert!(info.is_folder()); + } + + #[test] + fn object_info_is_file() { + let info = ObjectInfo { + format: ObjectFormatCode::Jpeg, + association_type: AssociationType::None, + ..Default::default() + }; + assert!(info.is_file()); + assert!(!info.is_folder()); + } + + #[test] + fn object_info_parse_insufficient_bytes() { + let buf = vec![0x00; 10]; // Not enough bytes + assert!(ObjectInfo::from_bytes(&buf).is_err()); + } + + #[test] + fn object_info_default() { + let info = ObjectInfo::default(); + assert_eq!(info.storage_id, StorageId::default()); + assert_eq!(info.format, ObjectFormatCode::Undefined); + assert_eq!(info.protection_status, ProtectionStatus::None); + assert_eq!(info.size, 0); + assert_eq!(info.filename, ""); + assert!(info.created.is_none()); + assert!(info.modified.is_none()); + } + + // Fuzz tests using shared macros + crate::fuzz_bytes!(fuzz_object_info, ObjectInfo, 200); + + #[test] + fn object_info_minimum_valid() { + // ObjectInfo has many fixed fields before strings + // StorageID(4) + Format(2) + Protection(2) + Size(4) + ThumbFormat(2) + ThumbSize(4) + + // ThumbW(4) + ThumbH(4) + ImgW(4) + ImgH(4) + BitDepth(4) + Parent(4) + AssocType(2) + + // AssocDesc(4) + SeqNum(4) = 52 bytes + 4 strings + assert!(ObjectInfo::from_bytes(&[]).is_err()); + assert!(ObjectInfo::from_bytes(&[0; 51]).is_err()); + assert!(ObjectInfo::from_bytes(&[0; 52]).is_err()); // Still need string data + } + + #[test] + fn object_info_size_u32_max() { + // When size is u32::MAX (0xFFFFFFFF), it indicates >4GB file + let mut buf = build_file_object_info_bytes(); + // Replace size field (bytes 8-11, after StorageID(4) + Format(2) + Protection(2)) + buf[8] = 0xFF; + buf[9] = 0xFF; + buf[10] = 0xFF; + buf[11] = 0xFF; + + let info = ObjectInfo::from_bytes(&buf).unwrap(); + assert_eq!(info.size, u32::MAX as u64); + } +} diff --git a/vendor/mtp-rs/src/ptp/types/properties.rs b/vendor/mtp-rs/src/ptp/types/properties.rs new file mode 100644 index 0000000..c212265 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/types/properties.rs @@ -0,0 +1,772 @@ +//! Property-related types for MTP/PTP device properties. +//! +//! This module contains types for working with device properties: +//! - [`PropertyValue`]: A property value with its associated type +//! - [`PropertyFormType`]: Form type for property value constraints +//! - [`PropertyRange`]: Range constraint for a property value +//! - [`DevicePropDesc`]: Device property descriptor + +use crate::ptp::codes::{DevicePropertyCode, PropertyDataType}; +use crate::ptp::pack::{ + pack_i16, pack_i32, pack_i64, pack_i8, pack_string, pack_u16, pack_u32, pack_u64, pack_u8, + unpack_i16, unpack_i32, unpack_i64, unpack_i8, unpack_string, unpack_u16, unpack_u64, + unpack_u8, +}; + +// --- PropertyValue Enum --- + +/// A property value with its associated type. +/// +/// Used for device property values in `DevicePropDesc`, as well as +/// for get/set device property operations. +#[derive(Debug, Clone, PartialEq)] +pub enum PropertyValue { + /// Signed 8-bit integer. + Int8(i8), + /// Unsigned 8-bit integer. + Uint8(u8), + /// Signed 16-bit integer. + Int16(i16), + /// Unsigned 16-bit integer. + Uint16(u16), + /// Signed 32-bit integer. + Int32(i32), + /// Unsigned 32-bit integer. + Uint32(u32), + /// Signed 64-bit integer. + Int64(i64), + /// Unsigned 64-bit integer. + Uint64(u64), + /// UTF-16LE encoded string. + String(String), +} + +impl PropertyValue { + /// Serialize this property value to bytes. + pub fn to_bytes(&self) -> Vec { + match self { + PropertyValue::Int8(v) => pack_i8(*v).to_vec(), + PropertyValue::Uint8(v) => pack_u8(*v).to_vec(), + PropertyValue::Int16(v) => pack_i16(*v).to_vec(), + PropertyValue::Uint16(v) => pack_u16(*v).to_vec(), + PropertyValue::Int32(v) => pack_i32(*v).to_vec(), + PropertyValue::Uint32(v) => pack_u32(*v).to_vec(), + PropertyValue::Int64(v) => pack_i64(*v).to_vec(), + PropertyValue::Uint64(v) => pack_u64(*v).to_vec(), + PropertyValue::String(v) => pack_string(v), + } + } + + /// Parse a property value from bytes given the expected data type. + /// + /// Returns the parsed value and the number of bytes consumed. + pub fn from_bytes( + buf: &[u8], + data_type: PropertyDataType, + ) -> Result<(Self, usize), crate::Error> { + match data_type { + PropertyDataType::Int8 => { + let val = unpack_i8(buf)?; + Ok((PropertyValue::Int8(val), 1)) + } + PropertyDataType::Uint8 => { + let val = unpack_u8(buf)?; + Ok((PropertyValue::Uint8(val), 1)) + } + PropertyDataType::Int16 => { + let val = unpack_i16(buf)?; + Ok((PropertyValue::Int16(val), 2)) + } + PropertyDataType::Uint16 => { + let val = unpack_u16(buf)?; + Ok((PropertyValue::Uint16(val), 2)) + } + PropertyDataType::Int32 => { + let val = unpack_i32(buf)?; + Ok((PropertyValue::Int32(val), 4)) + } + PropertyDataType::Uint32 => { + let val = crate::ptp::pack::unpack_u32(buf)?; + Ok((PropertyValue::Uint32(val), 4)) + } + PropertyDataType::Int64 => { + let val = unpack_i64(buf)?; + Ok((PropertyValue::Int64(val), 8)) + } + PropertyDataType::Uint64 => { + let val = unpack_u64(buf)?; + Ok((PropertyValue::Uint64(val), 8)) + } + PropertyDataType::String => { + let (val, consumed) = unpack_string(buf)?; + Ok((PropertyValue::String(val), consumed)) + } + PropertyDataType::Undefined + | PropertyDataType::Int128 + | PropertyDataType::Uint128 + | PropertyDataType::Unknown(_) => Err(crate::Error::invalid_data(format!( + "unsupported property data type: {:?}", + data_type + ))), + } + } + + /// Get the data type of this property value. + #[must_use] + pub fn data_type(&self) -> PropertyDataType { + match self { + PropertyValue::Int8(_) => PropertyDataType::Int8, + PropertyValue::Uint8(_) => PropertyDataType::Uint8, + PropertyValue::Int16(_) => PropertyDataType::Int16, + PropertyValue::Uint16(_) => PropertyDataType::Uint16, + PropertyValue::Int32(_) => PropertyDataType::Int32, + PropertyValue::Uint32(_) => PropertyDataType::Uint32, + PropertyValue::Int64(_) => PropertyDataType::Int64, + PropertyValue::Uint64(_) => PropertyDataType::Uint64, + PropertyValue::String(_) => PropertyDataType::String, + } + } +} + +// --- PropertyFormType Enum --- + +/// Form type for property value constraints. +/// +/// Describes how allowed values are specified for a property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PropertyFormType { + /// No constraints (any value valid). + #[default] + None, + /// Value must be within a range (min, max, step). + Range, + /// Value must be one of an enumerated set. + Enumeration, + /// Unknown form type. + Unknown(u8), +} + +impl PropertyFormType { + /// Convert a raw u8 code to a PropertyFormType. + #[must_use] + pub fn from_code(code: u8) -> Self { + match code { + 0x00 => PropertyFormType::None, + 0x01 => PropertyFormType::Range, + 0x02 => PropertyFormType::Enumeration, + _ => PropertyFormType::Unknown(code), + } + } + + /// Convert a PropertyFormType to its raw u8 value. + #[must_use] + pub fn to_code(self) -> u8 { + match self { + PropertyFormType::None => 0x00, + PropertyFormType::Range => 0x01, + PropertyFormType::Enumeration => 0x02, + PropertyFormType::Unknown(code) => code, + } + } +} + +// --- PropertyRange Struct --- + +/// Range constraint for a property value. +/// +/// Used when `PropertyFormType::Range` is specified. +#[derive(Debug, Clone, PartialEq)] +pub struct PropertyRange { + /// Minimum allowed value. + pub min: PropertyValue, + /// Maximum allowed value. + pub max: PropertyValue, + /// Step size between allowed values. + pub step: PropertyValue, +} + +impl PropertyRange { + /// Parse a PropertyRange from bytes given the data type. + /// + /// Returns the parsed range and the number of bytes consumed. + pub fn from_bytes( + buf: &[u8], + data_type: PropertyDataType, + ) -> Result<(Self, usize), crate::Error> { + let mut offset = 0; + + let (min, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + offset += consumed; + + let (max, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + offset += consumed; + + let (step, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + offset += consumed; + + Ok((PropertyRange { min, max, step }, offset)) + } + + /// Serialize this property range to bytes. + pub fn to_bytes(&self) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&self.min.to_bytes()); + buf.extend_from_slice(&self.max.to_bytes()); + buf.extend_from_slice(&self.step.to_bytes()); + buf + } +} + +// --- DevicePropDesc Structure --- + +/// Device property descriptor. +/// +/// Describes a device property including its type, current value, +/// default value, and allowed values/ranges. +/// +/// Returned by the GetDevicePropDesc operation. +#[derive(Debug, Clone)] +pub struct DevicePropDesc { + /// Property code identifying this property. + pub property_code: DevicePropertyCode, + /// Data type of the property value. + pub data_type: PropertyDataType, + /// Whether the property is writable (true) or read-only (false). + pub writable: bool, + /// Default/factory value. + pub default_value: PropertyValue, + /// Current value. + pub current_value: PropertyValue, + /// Form type (None, Range, or Enumeration). + pub form_type: PropertyFormType, + /// Allowed values (if form_type is Enumeration). + pub enum_values: Option>, + /// Value range (if form_type is Range). + pub range: Option, +} + +impl DevicePropDesc { + /// Parse a DevicePropDesc from bytes. + /// + /// The buffer should contain the DevicePropDesc dataset as returned + /// by GetDevicePropDesc. + pub fn from_bytes(buf: &[u8]) -> Result { + let mut offset = 0; + + // 1. PropertyCode (u16) + let property_code = DevicePropertyCode::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 2. DataType (u16) + let data_type = PropertyDataType::from(unpack_u16(&buf[offset..])?); + offset += 2; + + // 3. GetSet (u8): 0x00 = read-only, 0x01 = read-write + if buf.len() <= offset { + return Err(crate::Error::invalid_data( + "DevicePropDesc: insufficient bytes for GetSet", + )); + } + let writable = buf[offset] != 0x00; + offset += 1; + + // 4. DefaultValue (variable size based on data type) + let (default_value, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + offset += consumed; + + // 5. CurrentValue (variable size based on data type) + let (current_value, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + offset += consumed; + + // 6. FormFlag (u8) + if buf.len() <= offset { + return Err(crate::Error::invalid_data( + "DevicePropDesc: insufficient bytes for FormFlag", + )); + } + let form_type = PropertyFormType::from_code(buf[offset]); + offset += 1; + + // 7. Form data (depends on form_type) + let (enum_values, range) = match form_type { + PropertyFormType::None | PropertyFormType::Unknown(_) => (None, None), + PropertyFormType::Range => { + let (range, _consumed) = PropertyRange::from_bytes(&buf[offset..], data_type)?; + (None, Some(range)) + } + PropertyFormType::Enumeration => { + // Number of values (u16) + let count = unpack_u16(&buf[offset..])? as usize; + offset += 2; + + let mut values = Vec::with_capacity(count); + for _ in 0..count { + let (val, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; + values.push(val); + offset += consumed; + } + (Some(values), None) + } + }; + + Ok(DevicePropDesc { + property_code, + data_type, + writable, + default_value, + current_value, + form_type, + enum_values, + range, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ptp::pack::{pack_i16, pack_u16}; + use proptest::prelude::*; + + // --- PropertyValue tests --- + + #[test] + fn property_value_roundtrip() { + let values = [ + PropertyValue::Int8(-42), + PropertyValue::Uint8(100), + PropertyValue::Int16(-1000), + PropertyValue::Uint16(5000), + PropertyValue::Int32(-100000), + PropertyValue::Uint32(100000), + PropertyValue::Int64(-1_000_000_000), + PropertyValue::Uint64(1_000_000_000), + PropertyValue::String("Test".to_string()), + ]; + for val in &values { + let bytes = val.to_bytes(); + let (parsed, _) = PropertyValue::from_bytes(&bytes, val.data_type()).unwrap(); + assert_eq!(&parsed, val); + } + } + + #[test] + fn property_value_data_type() { + let cases: &[(PropertyValue, PropertyDataType)] = &[ + (PropertyValue::Int8(0), PropertyDataType::Int8), + (PropertyValue::Uint8(0), PropertyDataType::Uint8), + (PropertyValue::Int16(0), PropertyDataType::Int16), + (PropertyValue::Uint16(0), PropertyDataType::Uint16), + (PropertyValue::Int32(0), PropertyDataType::Int32), + (PropertyValue::Uint32(0), PropertyDataType::Uint32), + (PropertyValue::Int64(0), PropertyDataType::Int64), + (PropertyValue::Uint64(0), PropertyDataType::Uint64), + (PropertyValue::String("".into()), PropertyDataType::String), + ]; + for (val, expected) in cases { + assert_eq!(val.data_type(), *expected); + } + } + + #[test] + fn property_value_from_bytes_unsupported_type() { + for dt in [ + PropertyDataType::Undefined, + PropertyDataType::Int128, + PropertyDataType::Uint128, + PropertyDataType::Unknown(0x99), + ] { + assert!(PropertyValue::from_bytes(&[0x00], dt).is_err()); + } + } + + #[test] + fn property_value_from_bytes_insufficient_bytes() { + assert!(PropertyValue::from_bytes(&[], PropertyDataType::Int8).is_err()); + assert!(PropertyValue::from_bytes(&[0x00], PropertyDataType::Int16).is_err()); + assert!(PropertyValue::from_bytes(&[0x00, 0x00], PropertyDataType::Int32).is_err()); + assert!(PropertyValue::from_bytes(&[0x00; 7], PropertyDataType::Int64).is_err()); + } + + // --- PropertyFormType tests --- + + #[test] + fn property_form_type_from_code() { + for (code, expected) in [ + (0x00, PropertyFormType::None), + (0x01, PropertyFormType::Range), + (0x02, PropertyFormType::Enumeration), + ] { + assert_eq!(PropertyFormType::from_code(code), expected); + assert_eq!(expected.to_code(), code); + } + assert_eq!( + PropertyFormType::from_code(0x99), + PropertyFormType::Unknown(0x99) + ); + } + + #[test] + fn property_form_type_to_code() { + assert_eq!(PropertyFormType::None.to_code(), 0x00); + assert_eq!(PropertyFormType::Range.to_code(), 0x01); + assert_eq!(PropertyFormType::Enumeration.to_code(), 0x02); + assert_eq!(PropertyFormType::Unknown(0x99).to_code(), 0x99); + } + + #[test] + fn property_form_type_roundtrip() { + for f in [ + PropertyFormType::None, + PropertyFormType::Range, + PropertyFormType::Enumeration, + ] { + assert_eq!(PropertyFormType::from_code(f.to_code()), f); + } + } + + // --- PropertyRange tests --- + + #[test] + fn property_range_from_bytes_uint8() { + let buf = vec![0x00, 0x64, 0x01]; // min=0, max=100, step=1 + let (range, consumed) = PropertyRange::from_bytes(&buf, PropertyDataType::Uint8).unwrap(); + assert_eq!(range.min, PropertyValue::Uint8(0)); + assert_eq!(range.max, PropertyValue::Uint8(100)); + assert_eq!(range.step, PropertyValue::Uint8(1)); + assert_eq!(consumed, 3); + } + + #[test] + fn property_range_from_bytes_uint16() { + let buf = vec![0x64, 0x00, 0x00, 0x19, 0x64, 0x00]; // min=100, max=6400, step=100 + let (range, consumed) = PropertyRange::from_bytes(&buf, PropertyDataType::Uint16).unwrap(); + assert_eq!(range.min, PropertyValue::Uint16(100)); + assert_eq!(range.max, PropertyValue::Uint16(6400)); + assert_eq!(range.step, PropertyValue::Uint16(100)); + assert_eq!(consumed, 6); + } + + #[test] + fn property_range_to_bytes() { + let range = PropertyRange { + min: PropertyValue::Uint8(0), + max: PropertyValue::Uint8(100), + step: PropertyValue::Uint8(1), + }; + assert_eq!(range.to_bytes(), vec![0x00, 0x64, 0x01]); + } + + #[test] + fn property_range_roundtrip() { + let range = PropertyRange { + min: PropertyValue::Uint16(100), + max: PropertyValue::Uint16(6400), + step: PropertyValue::Uint16(100), + }; + let bytes = range.to_bytes(); + let (parsed, _) = PropertyRange::from_bytes(&bytes, PropertyDataType::Uint16).unwrap(); + assert_eq!(parsed.min, range.min); + assert_eq!(parsed.max, range.max); + assert_eq!(parsed.step, range.step); + } + + // --- DevicePropDesc tests --- + + fn build_battery_level_prop_desc(current: u8) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&pack_u16(0x5001)); // BatteryLevel + buf.extend_from_slice(&pack_u16(0x0002)); // UINT8 + buf.push(0x00); // read-only + buf.push(100); // default + buf.push(current); // current + buf.push(0x01); // Range + buf.extend([0, 100, 1]); // min, max, step + buf + } + + #[test] + fn device_prop_desc_parse_battery_level() { + let desc = DevicePropDesc::from_bytes(&build_battery_level_prop_desc(75)).unwrap(); + assert_eq!(desc.property_code, DevicePropertyCode::BatteryLevel); + assert_eq!(desc.data_type, PropertyDataType::Uint8); + assert!(!desc.writable); + assert_eq!(desc.current_value, PropertyValue::Uint8(75)); + assert_eq!(desc.form_type, PropertyFormType::Range); + let range = desc.range.unwrap(); + assert_eq!(range.min, PropertyValue::Uint8(0)); + assert_eq!(range.max, PropertyValue::Uint8(100)); + } + + fn build_iso_prop_desc() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&pack_u16(0x500F)); // ExposureIndex + buf.extend_from_slice(&pack_u16(0x0004)); // UINT16 + buf.push(0x01); // read-write + buf.extend_from_slice(&pack_u16(400)); // default + buf.extend_from_slice(&pack_u16(800)); // current + buf.push(0x02); // Enumeration + buf.extend_from_slice(&pack_u16(4)); // count + for iso in [100u16, 200, 400, 800] { + buf.extend_from_slice(&pack_u16(iso)); + } + buf + } + + #[test] + fn device_prop_desc_parse_iso_enumeration() { + let desc = DevicePropDesc::from_bytes(&build_iso_prop_desc()).unwrap(); + assert_eq!(desc.property_code, DevicePropertyCode::ExposureIndex); + assert!(desc.writable); + assert_eq!(desc.current_value, PropertyValue::Uint16(800)); + assert_eq!(desc.form_type, PropertyFormType::Enumeration); + let values = desc.enum_values.unwrap(); + assert_eq!( + values, + vec![ + PropertyValue::Uint16(100), + PropertyValue::Uint16(200), + PropertyValue::Uint16(400), + PropertyValue::Uint16(800), + ] + ); + } + + fn build_datetime_prop_desc() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&pack_u16(0x5011)); // DateTime + buf.extend_from_slice(&pack_u16(0xFFFF)); // String + buf.push(0x01); // read-write + buf.push(0x00); // default: empty + buf.extend_from_slice(&pack_string("20240315T120000")); + buf.push(0x00); // None + buf + } + + #[test] + fn device_prop_desc_parse_datetime_no_form() { + let desc = DevicePropDesc::from_bytes(&build_datetime_prop_desc()).unwrap(); + assert_eq!(desc.property_code, DevicePropertyCode::DateTime); + assert_eq!(desc.data_type, PropertyDataType::String); + assert_eq!( + desc.current_value, + PropertyValue::String("20240315T120000".into()) + ); + assert_eq!(desc.form_type, PropertyFormType::None); + } + + fn build_exposure_bias_prop_desc() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&pack_u16(0x5010)); // ExposureBiasCompensation + buf.extend_from_slice(&pack_u16(0x0003)); // INT16 + buf.push(0x01); + buf.extend_from_slice(&pack_i16(0)); // default + buf.extend_from_slice(&pack_i16(-1000)); // current + buf.push(0x01); // Range + buf.extend_from_slice(&pack_i16(-3000)); + buf.extend_from_slice(&pack_i16(3000)); + buf.extend_from_slice(&pack_i16(333)); + buf + } + + #[test] + fn device_prop_desc_parse_exposure_bias_signed() { + let desc = DevicePropDesc::from_bytes(&build_exposure_bias_prop_desc()).unwrap(); + assert_eq!( + desc.property_code, + DevicePropertyCode::ExposureBiasCompensation + ); + assert_eq!(desc.current_value, PropertyValue::Int16(-1000)); + let range = desc.range.unwrap(); + assert_eq!(range.min, PropertyValue::Int16(-3000)); + assert_eq!(range.max, PropertyValue::Int16(3000)); + } + + #[test] + fn device_prop_desc_parse_insufficient_bytes() { + assert!(DevicePropDesc::from_bytes(&[0x01]).is_err()); + assert!(DevicePropDesc::from_bytes(&[0x01, 0x50]).is_err()); + } + + #[test] + fn device_prop_desc_minimum_valid() { + assert!(DevicePropDesc::from_bytes(&[]).is_err()); + assert!(DevicePropDesc::from_bytes(&[0; 4]).is_err()); + } + + // --- Property-based tests --- + + proptest! { + #[test] + fn prop_property_form_type_known_roundtrip(code in 0u8..=2u8) { + let form = PropertyFormType::from_code(code); + prop_assert_eq!(form.to_code(), code); + } + + #[test] + fn prop_property_form_type_unknown_preserves_code(code in 3u8..=u8::MAX) { + let form = PropertyFormType::from_code(code); + prop_assert_eq!(form, PropertyFormType::Unknown(code)); + prop_assert_eq!(form.to_code(), code); + } + + #[test] + fn prop_property_value_int8_roundtrip(val: i8) { + let pv = PropertyValue::Int8(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int8).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_int16_roundtrip(val: i16) { + let pv = PropertyValue::Int16(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int16).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_int32_roundtrip(val: i32) { + let pv = PropertyValue::Int32(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int32).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_int64_roundtrip(val: i64) { + let pv = PropertyValue::Int64(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int64).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_uint16_roundtrip(val: u16) { + let pv = PropertyValue::Uint16(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint16).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_uint32_roundtrip(val: u32) { + let pv = PropertyValue::Uint32(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint32).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_value_uint64_roundtrip(val: u64) { + let pv = PropertyValue::Uint64(val); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint64).unwrap(); + prop_assert_eq!(parsed, pv); + } + + #[test] + fn prop_property_range_uint8_roundtrip(min: u8, max: u8, step: u8) { + let range = PropertyRange { + min: PropertyValue::Uint8(min), + max: PropertyValue::Uint8(max), + step: PropertyValue::Uint8(step), + }; + let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint8).unwrap(); + prop_assert_eq!(parsed.min, range.min); + prop_assert_eq!(parsed.max, range.max); + prop_assert_eq!(parsed.step, range.step); + } + + #[test] + fn prop_property_range_uint16_roundtrip(min: u16, max: u16, step: u16) { + let range = PropertyRange { + min: PropertyValue::Uint16(min), + max: PropertyValue::Uint16(max), + step: PropertyValue::Uint16(step), + }; + let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint16).unwrap(); + prop_assert_eq!(parsed.min, range.min); + prop_assert_eq!(parsed.max, range.max); + } + + #[test] + fn prop_property_range_int16_roundtrip(min: i16, max: i16, step: i16) { + let range = PropertyRange { + min: PropertyValue::Int16(min), + max: PropertyValue::Int16(max), + step: PropertyValue::Int16(step), + }; + let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Int16).unwrap(); + prop_assert_eq!(parsed.min, range.min); + prop_assert_eq!(parsed.max, range.max); + } + + #[test] + fn prop_property_range_uint32_roundtrip(min: u32, max: u32, step: u32) { + let range = PropertyRange { + min: PropertyValue::Uint32(min), + max: PropertyValue::Uint32(max), + step: PropertyValue::Uint32(step), + }; + let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint32).unwrap(); + prop_assert_eq!(parsed.min, range.min); + prop_assert_eq!(parsed.max, range.max); + } + } + + // --- Fuzz tests --- + + fn valid_property_string() -> impl Strategy { + prop::collection::vec( + prop::char::range('\u{0000}', '\u{D7FF}') + .prop_union(prop::char::range('\u{E000}', '\u{FFFF}')), + 0..50, + ) + .prop_map(|chars| chars.into_iter().collect::()) + } + + proptest! { + #[test] + fn prop_property_value_string_roundtrip(s in valid_property_string()) { + let s = if s.chars().count() > 254 { s.chars().take(254).collect() } else { s }; + let pv = PropertyValue::String(s.clone()); + let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::String).unwrap(); + prop_assert_eq!(parsed, PropertyValue::String(s)); + } + + #[test] + fn fuzz_property_value_truncated(data_type_code in 1u16..=8u16, bytes in prop::collection::vec(any::(), 0..20)) { + let _ = PropertyValue::from_bytes(&bytes, PropertyDataType::from(data_type_code)); + } + + #[test] + fn fuzz_property_value_empty(data_type_code in 1u16..=8u16) { + prop_assert!(PropertyValue::from_bytes(&[], PropertyDataType::from(data_type_code)).is_err()); + } + + #[test] + fn fuzz_property_value_string_garbage(bytes in prop::collection::vec(any::(), 0..50)) { + let _ = PropertyValue::from_bytes(&bytes, PropertyDataType::String); + } + + #[test] + fn fuzz_property_value_unsupported(bytes in prop::collection::vec(any::(), 0..20)) { + prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Undefined).is_err()); + prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Int128).is_err()); + prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Uint128).is_err()); + } + + #[test] + fn fuzz_property_value_unknown_type(unknown_code in 11u16..=0xFFFEu16, bytes in prop::collection::vec(any::(), 0..20)) { + prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Unknown(unknown_code)).is_err()); + } + + #[test] + fn fuzz_property_range_truncated(data_type_code in 1u16..=8u16, bytes in prop::collection::vec(any::(), 0..20)) { + let _ = PropertyRange::from_bytes(&bytes, PropertyDataType::from(data_type_code)); + } + + #[test] + fn fuzz_property_range_wrong_type(bytes in prop::collection::vec(any::(), 0..20)) { + prop_assert!(PropertyRange::from_bytes(&bytes, PropertyDataType::Undefined).is_err()); + prop_assert!(PropertyRange::from_bytes(&bytes, PropertyDataType::Unknown(0x1234)).is_err()); + } + } + + crate::fuzz_bytes!(fuzz_device_prop_desc, DevicePropDesc, 200); +} diff --git a/vendor/mtp-rs/src/ptp/types/storage.rs b/vendor/mtp-rs/src/ptp/types/storage.rs new file mode 100644 index 0000000..ad9d1a5 --- /dev/null +++ b/vendor/mtp-rs/src/ptp/types/storage.rs @@ -0,0 +1,243 @@ +//! Storage-related type enums for MTP/PTP. +//! +//! This module contains enums for describing storage characteristics: +//! - [`StorageType`]: Type of storage medium (ROM, RAM, etc.) +//! - [`FilesystemType`]: Type of filesystem on the storage +//! - [`AccessCapability`]: Read/write access capabilities +//! - [`ProtectionStatus`]: Object protection status +//! - [`AssociationType`]: Association type for objects (folder/container type) + +use num_enum::{FromPrimitive, IntoPrimitive}; + +/// Type of storage medium. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum StorageType { + /// Undefined storage type. + Undefined = 0, + /// Fixed ROM (e.g., internal flash). + FixedRom = 1, + /// Removable ROM. + RemovableRom = 2, + /// Fixed RAM. + FixedRam = 3, + /// Removable RAM (e.g., SD card). + RemovableRam = 4, + /// Unknown storage type code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all +impl Default for StorageType { + fn default() -> Self { + Self::Undefined + } +} + +/// Type of filesystem on the storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum FilesystemType { + /// Undefined filesystem type. + Undefined = 0, + /// Generic flat filesystem (no folders). + GenericFlat = 1, + /// Generic hierarchical filesystem (with folders). + GenericHierarchical = 2, + /// DCF (Design rule for Camera File system). + Dcf = 3, + /// Unknown filesystem type code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all +impl Default for FilesystemType { + fn default() -> Self { + Self::Undefined + } +} + +/// Access capability of the storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum AccessCapability { + /// Read-write access. + ReadWrite = 0, + /// Read-only, deletion not allowed. + ReadOnlyWithoutDeletion = 1, + /// Read-only, deletion allowed. + ReadOnlyWithDeletion = 2, + /// Unknown access capability code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all +impl Default for AccessCapability { + fn default() -> Self { + Self::ReadWrite + } +} + +/// Protection status of an object. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum ProtectionStatus { + /// No protection. + None = 0, + /// Read-only protection. + ReadOnly = 1, + /// Unknown protection status code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all +impl Default for ProtectionStatus { + fn default() -> Self { + Self::None + } +} + +/// Association type for objects (folder/container type). +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +#[repr(u16)] +pub enum AssociationType { + /// No association (regular file). + None = 0, + /// Generic folder. + GenericFolder = 1, + /// Unknown association type code. + #[num_enum(catch_all)] + Unknown(u16), +} + +#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all +impl Default for AssociationType { + fn default() -> Self { + Self::None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn storage_type_conversions() { + for (code, expected) in [ + (0, StorageType::Undefined), + (1, StorageType::FixedRom), + (2, StorageType::RemovableRom), + (3, StorageType::FixedRam), + (4, StorageType::RemovableRam), + ] { + assert_eq!(StorageType::from(code), expected); + assert_eq!(u16::from(expected), code); + } + assert_eq!(StorageType::from(99u16), StorageType::Unknown(99)); + assert_eq!(StorageType::default(), StorageType::Undefined); + } + + #[test] + fn filesystem_type_conversions() { + for (code, expected) in [ + (0, FilesystemType::Undefined), + (1, FilesystemType::GenericFlat), + (2, FilesystemType::GenericHierarchical), + (3, FilesystemType::Dcf), + ] { + assert_eq!(FilesystemType::from(code), expected); + assert_eq!(u16::from(expected), code); + } + assert_eq!(FilesystemType::from(99u16), FilesystemType::Unknown(99)); + assert_eq!(FilesystemType::default(), FilesystemType::Undefined); + } + + #[test] + fn access_capability_conversions() { + for (code, expected) in [ + (0, AccessCapability::ReadWrite), + (1, AccessCapability::ReadOnlyWithoutDeletion), + (2, AccessCapability::ReadOnlyWithDeletion), + ] { + assert_eq!(AccessCapability::from(code), expected); + assert_eq!(u16::from(expected), code); + } + assert_eq!(AccessCapability::from(99u16), AccessCapability::Unknown(99)); + assert_eq!(AccessCapability::default(), AccessCapability::ReadWrite); + } + + #[test] + fn protection_status_conversions() { + for (code, expected) in [(0, ProtectionStatus::None), (1, ProtectionStatus::ReadOnly)] { + assert_eq!(ProtectionStatus::from(code), expected); + assert_eq!(u16::from(expected), code); + } + assert_eq!(ProtectionStatus::from(99u16), ProtectionStatus::Unknown(99)); + assert_eq!(ProtectionStatus::default(), ProtectionStatus::None); + } + + #[test] + fn association_type_conversions() { + for (code, expected) in [ + (0, AssociationType::None), + (1, AssociationType::GenericFolder), + ] { + assert_eq!(AssociationType::from(code), expected); + assert_eq!(u16::from(expected), code); + } + assert_eq!(AssociationType::from(99u16), AssociationType::Unknown(99)); + assert_eq!(AssociationType::default(), AssociationType::None); + } + + proptest! { + #[test] + fn prop_storage_type_roundtrip(code: u16) { + let st = StorageType::from(code); + prop_assert_eq!(u16::from(st), code); + if code > 4 { + prop_assert_eq!(st, StorageType::Unknown(code)); + } + } + + #[test] + fn prop_filesystem_type_roundtrip(code: u16) { + let ft = FilesystemType::from(code); + prop_assert_eq!(u16::from(ft), code); + if code > 3 { + prop_assert_eq!(ft, FilesystemType::Unknown(code)); + } + } + + #[test] + fn prop_access_capability_roundtrip(code: u16) { + let ac = AccessCapability::from(code); + prop_assert_eq!(u16::from(ac), code); + if code > 2 { + prop_assert_eq!(ac, AccessCapability::Unknown(code)); + } + } + + #[test] + fn prop_protection_status_roundtrip(code: u16) { + let ps = ProtectionStatus::from(code); + prop_assert_eq!(u16::from(ps), code); + if code > 1 { + prop_assert_eq!(ps, ProtectionStatus::Unknown(code)); + } + } + + #[test] + fn prop_association_type_roundtrip(code: u16) { + let at = AssociationType::from(code); + prop_assert_eq!(u16::from(at), code); + if code > 1 { + prop_assert_eq!(at, AssociationType::Unknown(code)); + } + } + } +} diff --git a/vendor/mtp-rs/src/transport/mock.rs b/vendor/mtp-rs/src/transport/mock.rs new file mode 100644 index 0000000..6bff636 --- /dev/null +++ b/vendor/mtp-rs/src/transport/mock.rs @@ -0,0 +1,301 @@ +//! Mock transport for testing. + +use super::Transport; +use async_trait::async_trait; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::time::Duration; + +/// Mock transport for testing MTP protocol logic without USB. +/// +/// Example: +/// ```rust,ignore +/// let mut mock = MockTransport::new(); +/// mock.expect_send(vec![0x10, 0x00, ...]); // Expected command +/// mock.queue_response(vec![0x0C, 0x00, ...]); // OK response +/// +/// // ... run test ... +/// +/// mock.verify().expect("Verification failed"); +/// ``` +pub struct MockTransport { + expected_sends: Mutex>>, + queued_responses: Mutex>>, + queued_interrupts: Mutex>>, + actual_sends: Mutex>>, + cancel_calls: Mutex>, + cancel_results: Mutex>>, +} + +impl MockTransport { + /// Create a new mock transport with no expectations or queued responses. + #[must_use] + pub fn new() -> Self { + Self { + expected_sends: Mutex::new(VecDeque::new()), + queued_responses: Mutex::new(VecDeque::new()), + queued_interrupts: Mutex::new(VecDeque::new()), + actual_sends: Mutex::new(Vec::new()), + cancel_calls: Mutex::new(Vec::new()), + cancel_results: Mutex::new(VecDeque::new()), + } + } + + /// Expect a specific byte sequence to be sent. + /// If sends don't match expectations, verify() will fail. + pub fn expect_send(&self, data: Vec) { + self.expected_sends.lock().push_back(data); + } + + /// Queue a response to be returned by receive_bulk(). + pub fn queue_response(&self, data: Vec) { + self.queued_responses.lock().push_back(data); + } + + /// Queue an interrupt response to be returned by receive_interrupt(). + pub fn queue_interrupt(&self, data: Vec) { + self.queued_interrupts.lock().push_back(data); + } + + /// Verify all expected sends occurred and all responses were consumed. + pub fn verify(&self) -> Result<(), String> { + let expected = self.expected_sends.lock(); + let responses = self.queued_responses.lock(); + let interrupts = self.queued_interrupts.lock(); + let cancel_results = self.cancel_results.lock(); + + let mut errors = Vec::new(); + + if !expected.is_empty() { + errors.push(format!( + "{} expected send(s) were not received", + expected.len() + )); + } + + if !responses.is_empty() { + errors.push(format!( + "{} queued response(s) were not consumed", + responses.len() + )); + } + + if !interrupts.is_empty() { + errors.push(format!( + "{} queued interrupt(s) were not consumed", + interrupts.len() + )); + } + + if !cancel_results.is_empty() { + errors.push(format!( + "{} queued cancel result(s) were not consumed", + cancel_results.len() + )); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + /// Get all data that was actually sent (for inspection in tests). + pub fn get_sends(&self) -> Vec> { + self.actual_sends.lock().clone() + } + + /// Queue a result for the next `cancel_transfer` call. + pub fn queue_cancel_result(&self, result: Result<(), crate::Error>) { + self.cancel_results.lock().push_back(result); + } + + /// Get all transaction IDs passed to `cancel_transfer`. + pub fn get_cancel_calls(&self) -> Vec { + self.cancel_calls.lock().clone() + } + + /// Clear all expectations and queued responses. + pub fn reset(&self) { + self.expected_sends.lock().clear(); + self.queued_responses.lock().clear(); + self.queued_interrupts.lock().clear(); + self.actual_sends.lock().clear(); + self.cancel_calls.lock().clear(); + self.cancel_results.lock().clear(); + } +} + +impl Default for MockTransport { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Transport for MockTransport { + async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { + // Store sent data for verification + self.actual_sends.lock().push(data.to_vec()); + + // If expectations exist, verify they match + let expected = self.expected_sends.lock().pop_front(); + if let Some(expected_data) = expected { + if data != expected_data.as_slice() { + return Err(crate::Error::invalid_data(format!( + "send mismatch: expected {:?}, got {:?}", + expected_data, data + ))); + } + } + + Ok(()) + } + + async fn receive_bulk(&self, _max_size: usize) -> Result, crate::Error> { + // Return next queued response or error if none + self.queued_responses + .lock() + .pop_front() + .ok_or(crate::Error::NoDevice) + } + + async fn receive_interrupt(&self) -> Result, crate::Error> { + // Return next queued interrupt or error if none + self.queued_interrupts + .lock() + .pop_front() + .ok_or(crate::Error::NoDevice) + } + + async fn cancel_transfer( + &self, + transaction_id: u32, + _idle_timeout: Duration, + ) -> Result<(), crate::Error> { + self.cancel_calls.lock().push(transaction_id); + self.cancel_results.lock().pop_front().unwrap_or(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_send_receive() { + let mock = MockTransport::new(); + mock.queue_response(vec![1, 2, 3]); + + mock.send_bulk(&[4, 5, 6]).await.unwrap(); + let response = mock.receive_bulk(100).await.unwrap(); + + assert_eq!(response, vec![1, 2, 3]); + assert_eq!(mock.get_sends(), vec![vec![4, 5, 6]]); + } + + #[tokio::test] + async fn test_expected_send_matches() { + let mock = MockTransport::new(); + mock.expect_send(vec![1, 2, 3]); + + mock.send_bulk(&[1, 2, 3]).await.unwrap(); + mock.verify().unwrap(); + } + + #[tokio::test] + async fn test_expected_send_mismatch() { + let mock = MockTransport::new(); + mock.expect_send(vec![1, 2, 3]); + + let result = mock.send_bulk(&[1, 2, 4]).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_verify_fails_with_unconsumed_expectations() { + let mock = MockTransport::new(); + mock.expect_send(vec![1, 2, 3]); + + // Don't send anything + let result = mock.verify(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_verify_fails_with_unconsumed_responses() { + let mock = MockTransport::new(); + mock.queue_response(vec![1, 2, 3]); + + // Don't receive anything + let result = mock.verify(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_receive_bulk_empty_queue_returns_error() { + let mock = MockTransport::new(); + + let result = mock.receive_bulk(100).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_receive_interrupt() { + let mock = MockTransport::new(); + mock.queue_interrupt(vec![10, 20, 30]); + + let result = mock.receive_interrupt().await.unwrap(); + assert_eq!(result, vec![10, 20, 30]); + } + + #[tokio::test] + async fn test_receive_interrupt_empty_queue_returns_error() { + let mock = MockTransport::new(); + + let result = mock.receive_interrupt().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_reset_clears_all_state() { + let mock = MockTransport::new(); + mock.expect_send(vec![1, 2, 3]); + mock.queue_response(vec![4, 5, 6]); + mock.queue_interrupt(vec![7, 8, 9]); + mock.send_bulk(&[10, 11, 12]).await.ok(); + + mock.reset(); + + assert!(mock.get_sends().is_empty()); + mock.verify().unwrap(); // Should pass since everything was cleared + } + + #[tokio::test] + async fn test_multiple_sends_and_responses() { + let mock = MockTransport::new(); + mock.expect_send(vec![1, 2]); + mock.expect_send(vec![3, 4]); + mock.queue_response(vec![5, 6]); + mock.queue_response(vec![7, 8]); + + mock.send_bulk(&[1, 2]).await.unwrap(); + mock.send_bulk(&[3, 4]).await.unwrap(); + + let r1 = mock.receive_bulk(100).await.unwrap(); + let r2 = mock.receive_bulk(100).await.unwrap(); + + assert_eq!(r1, vec![5, 6]); + assert_eq!(r2, vec![7, 8]); + mock.verify().unwrap(); + } + + #[tokio::test] + async fn test_default_impl() { + let mock = MockTransport::default(); + mock.queue_response(vec![1, 2, 3]); + let response = mock.receive_bulk(100).await.unwrap(); + assert_eq!(response, vec![1, 2, 3]); + } +} diff --git a/vendor/mtp-rs/src/transport/mod.rs b/vendor/mtp-rs/src/transport/mod.rs new file mode 100644 index 0000000..bdcc647 --- /dev/null +++ b/vendor/mtp-rs/src/transport/mod.rs @@ -0,0 +1,72 @@ +//! USB transport abstraction layer. + +#[cfg(test)] +pub mod mock; +pub mod nusb; + +#[cfg(feature = "virtual-device")] +pub mod virtual_device; + +pub use self::nusb::{NusbTransport, UsbDeviceInfo}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; +use std::pin::Pin; +use std::time::Duration; + +/// A boxed stream of byte chunks used by [`Transport::send_bulk_streaming`]. +pub type BulkStream<'a> = Pin> + Send + 'a>>; + +/// Transport trait for MTP/PTP communication. +/// +/// Abstracts USB communication to enable testing with mock transport. +#[async_trait] +pub trait Transport: Send + Sync { + /// Send data on the bulk OUT endpoint. + async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error>; + + /// Send data as a continuous bulk transfer from a stream of chunks. + /// + /// All chunks are sent as one logical USB transfer, properly terminated + /// with a short packet or ZLP. This avoids buffering the entire payload + /// in memory. + /// + /// The default implementation collects all chunks and calls `send_bulk`. + /// USB transports should override this to use native streaming writes. + async fn send_bulk_streaming(&self, chunks: BulkStream<'_>) -> Result<(), crate::Error> { + use futures::StreamExt; + let mut buffer = Vec::new(); + let mut stream = chunks; + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(crate::Error::Io)?; + buffer.extend_from_slice(&chunk); + } + self.send_bulk(&buffer).await + } + + /// Receive data from the bulk IN endpoint. + /// + /// `max_size` is the maximum bytes to receive in one call. + async fn receive_bulk(&self, max_size: usize) -> Result, crate::Error>; + + /// Receive event data from the interrupt IN endpoint. + /// + /// This may block until an event is available. + async fn receive_interrupt(&self) -> Result, crate::Error>; + + /// Cancel an in-progress transfer using the USB Still Image Class mechanism. + /// + /// Sends a CLASS_CANCEL control request (`bRequest=0x64`), then drains + /// remaining data from the bulk IN and interrupt pipes. After this call, + /// the session is clean and ready for the next operation. + /// + /// `idle_timeout` controls how long to wait during drain steps before + /// assuming each pipe is clear. 300ms (matching libmtp / Windows) is + /// the recommended default; see [`mtp::DEFAULT_CANCEL_TIMEOUT`](crate::mtp::DEFAULT_CANCEL_TIMEOUT). + async fn cancel_transfer( + &self, + transaction_id: u32, + idle_timeout: Duration, + ) -> Result<(), crate::Error>; +} diff --git a/vendor/mtp-rs/src/transport/nusb.rs b/vendor/mtp-rs/src/transport/nusb.rs new file mode 100644 index 0000000..3fcd0a0 --- /dev/null +++ b/vendor/mtp-rs/src/transport/nusb.rs @@ -0,0 +1,777 @@ +//! USB transport implementation using nusb. + +use super::{BulkStream, Transport}; +use async_trait::async_trait; +use futures::lock::Mutex; +use futures_timer::Delay; +use nusb::descriptors::{InterfaceDescriptor, TransferType}; +use nusb::transfer::{ + Buffer, Bulk, ControlOut, ControlType, Direction, In, Interrupt, Out, Recipient, TransferError, +}; +use nusb::MaybeFuture; +use std::time::Duration; + +/// MTP interface class code (Still Image). +const MTP_CLASS_IMAGE: u8 = 0x06; +/// MTP interface class code (Vendor-specific). +const MTP_CLASS_VENDOR: u8 = 0xFF; +/// MTP subclass code. +const MTP_SUBCLASS: u8 = 0x01; +/// MTP protocol code (PTP). +const MTP_PROTOCOL: u8 = 0x01; + +// USB Still Image Class (SIC) cancel constants. +/// SIC Cancel Request bRequest code. +const SIC_CANCEL_REQUEST: u8 = 0x64; +/// CancelTransaction event code for the cancel payload. +const SIC_CANCEL_EVENT_CODE: u16 = 0x4001; + +/// USB device information with topology-based location ID. +#[derive(Debug, Clone)] +pub struct UsbDeviceInfo { + /// USB vendor ID + pub vendor_id: u16, + /// USB product ID + pub product_id: u16, + /// Manufacturer name (e.g., "Google", "Samsung") + pub manufacturer: Option, + /// Product name (e.g., "Pixel 9 Pro XL") + pub product: Option, + /// Device serial number (if available) + pub serial_number: Option, + /// USB location identifier derived from bus and port topology (stable per port) + pub location_id: u64, + /// Reference to the underlying nusb device info for opening + nusb_info: nusb::DeviceInfo, +} + +impl UsbDeviceInfo { + /// Open the USB device. + pub fn open(&self) -> Result { + self.nusb_info.open().wait() + } +} + +/// USB transport implementation using nusb. +pub struct NusbTransport { + interface: nusb::Interface, + interface_number: u8, + bulk_in: Mutex>, + bulk_out: Mutex>, + interrupt_in: Mutex>, + /// Timeout for bulk transfers (sending commands, receiving data). + timeout: Duration, +} + +impl NusbTransport { + /// Default timeout for bulk transfers (30 seconds for large file transfers). + pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + + /// Default buffer size for interrupt transfers. + const INTERRUPT_BUFFER_SIZE: usize = 64; + + /// List all available MTP devices with location IDs. + pub fn list_mtp_devices() -> Result, crate::Error> { + Self::list_mtp_devices_with_known(&[]) + } + + /// List all available MTP devices, including additional devices identified + /// by the given VID/PID pairs. + /// + /// Devices matching the provided VID/PID pairs are included in the results + /// even if their USB descriptors don't match standard MTP class codes. + pub fn list_mtp_devices_with_known( + known: &[(u16, u16)], + ) -> Result, crate::Error> { + let devices = nusb::list_devices() + .wait() + .map_err(crate::Error::Usb)? + .filter(|dev| Self::is_mtp_device(dev, known)) + .map(|dev| { + let location_id = location_id_from_topology(&dev); + UsbDeviceInfo { + vendor_id: dev.vendor_id(), + product_id: dev.product_id(), + manufacturer: dev.manufacturer_string().map(String::from), + product: dev.product_string().map(String::from), + serial_number: dev.serial_number().map(String::from), + location_id, + nusb_info: dev, + } + }) + .collect(); + Ok(devices) + } + + /// Check if a device info represents an MTP device. + /// + /// A device is considered MTP if it matches standard MTP class codes, has + /// an interface with the MTP endpoint layout, or matches one of the + /// caller-provided VID/PID pairs (used for devices with non-standard USB + /// descriptors that still speak MTP). + fn is_mtp_device(dev: &nusb::DeviceInfo, known: &[(u16, u16)]) -> bool { + // Fast path: caller-supplied known devices that may use non-standard descriptors. + if known + .iter() + .any(|&(v, p)| v == dev.vendor_id() && p == dev.product_id()) + { + return true; + } + + // Check device class/subclass/protocol at device level. + if Self::is_mtp_class(dev.class(), dev.subclass(), dev.protocol()) { + return true; + } + + // Many devices are composite (class 0) or vendor-specific (class 0xFF) + // with MTP on one interface. Only inspect these further. + if dev.class() != 0 && dev.class() != MTP_CLASS_VENDOR { + return false; + } + + // Check interface-level class info available from DeviceInfo without opening. + for intf in dev.interfaces() { + if Self::is_mtp_class(intf.class(), intf.subclass(), intf.protocol()) { + return true; + } + } + + // Fall back to opening the device and inspecting full configuration descriptors. + // This also catches vendor-specific interfaces (class 0xFF) that use non-standard + // subclass/protocol but have the MTP endpoint layout (e.g. Amazon Kindle). + if let Ok(device) = dev.open().wait() { + if let Ok(config) = device.active_configuration() { + for interface in config.interfaces() { + if let Some(alt) = interface.alt_settings().next() { + if Self::is_mtp_interface(&alt) { + return true; + } + } + } + } + } + + false + } + + /// Check if class/subclass/protocol match standard MTP identifiers. + fn is_mtp_class(class: u8, subclass: u8, protocol: u8) -> bool { + (class == MTP_CLASS_IMAGE || class == MTP_CLASS_VENDOR) + && subclass == MTP_SUBCLASS + && protocol == MTP_PROTOCOL + } + + /// Check if an interface descriptor looks like an MTP interface. + /// + /// Matches standard MTP class/subclass/protocol, and also vendor-specific + /// interfaces (class 0xFF) with non-standard subclass/protocol that have + /// the MTP endpoint layout (bulk IN + bulk OUT + interrupt IN). Some devices + /// like Amazon Kindle use vendor-specific descriptors while still speaking MTP. + fn is_mtp_interface(alt: &InterfaceDescriptor) -> bool { + if Self::is_mtp_class(alt.class(), alt.subclass(), alt.protocol()) { + return true; + } + // For vendor-specific class, subclass and protocol are vendor-defined, + // so we can't rely on them. Use endpoint layout as a heuristic instead. + alt.class() == MTP_CLASS_VENDOR && Self::has_mtp_endpoint_layout(alt) + } + + /// Check if an interface has the MTP endpoint layout: + /// one bulk IN, one bulk OUT, and one interrupt IN endpoint. + fn has_mtp_endpoint_layout(alt: &InterfaceDescriptor) -> bool { + let mut bulk_in = false; + let mut bulk_out = false; + let mut interrupt_in = false; + for ep in alt.endpoints() { + match (ep.direction(), ep.transfer_type()) { + (Direction::In, TransferType::Bulk) => bulk_in = true, + (Direction::Out, TransferType::Bulk) => bulk_out = true, + (Direction::In, TransferType::Interrupt) => interrupt_in = true, + _ => {} + } + } + bulk_in && bulk_out && interrupt_in + } + + /// Whether a `claim_interface` failure looks like the OS hasn't published + /// the interface yet (rather than a permanent error). + /// + /// On macOS, vendor-class or class-0 devices that IOKit doesn't + /// auto-configure end up with no `IOUSBHostInterface` services published, + /// even when the device's configuration descriptor reports otherwise. + /// The resulting `claim_interface` error is `NotFound` — there's nothing + /// for nusb to claim — and the fix is to issue `SetConfiguration(1)`, + /// which makes IOKit publish the interface objects. + #[cfg(target_os = "macos")] + fn is_interface_unpublished(e: &nusb::Error) -> bool { + matches!(e.kind(), nusb::ErrorKind::NotFound) + } + + /// Open a specific device and claim the MTP interface. + pub async fn open(device: nusb::Device) -> Result { + Self::open_with_timeout(device, Self::DEFAULT_TIMEOUT).await + } + + /// Open with custom bulk transfer timeout. + /// + /// The interface scan first looks for a strict MTP-class interface; if none + /// is found, it falls back to any interface with the MTP endpoint layout + /// (bulk IN + bulk OUT + interrupt IN). This relaxed fallback supports + /// legacy devices that report a non-standard interface class — the caller + /// has already hand-picked the device, so the scan can be permissive at + /// this point. + pub async fn open_with_timeout( + device: nusb::Device, + timeout: Duration, + ) -> Result { + // Find the MTP interface + let config = device.active_configuration().map_err(|e| { + crate::Error::invalid_data(format!("Failed to get configuration: {}", e)) + })?; + + let mut mtp_interface_number = None; + let mut bulk_in_addr = None; + let mut bulk_out_addr = None; + let mut interrupt_in_addr = None; + + // Two-pass scan: prefer a strictly-matching MTP interface, but fall + // back to any interface with the MTP endpoint layout. The caller has + // already hand-picked the device, so a permissive fallback is safe and + // supports legacy devices that report a non-standard interface class. + let pick = |strict: bool| { + for interface in config.interfaces() { + let Some(alt_setting) = interface.alt_settings().next() else { + continue; + }; + let matches = if strict { + Self::is_mtp_interface(&alt_setting) + } else { + Self::has_mtp_endpoint_layout(&alt_setting) + }; + if matches { + let mut bin = None; + let mut bout = None; + let mut iin = None; + for endpoint in alt_setting.endpoints() { + match (endpoint.direction(), endpoint.transfer_type()) { + (Direction::Out, TransferType::Bulk) => bout = Some(endpoint.address()), + (Direction::In, TransferType::Bulk) => bin = Some(endpoint.address()), + (Direction::In, TransferType::Interrupt) => { + iin = Some(endpoint.address()) + } + _ => {} + } + } + return Some((interface.interface_number(), bin, bout, iin)); + } + } + None + }; + + if let Some((n, bin, bout, iin)) = pick(true).or_else(|| pick(false)) { + mtp_interface_number = Some(n); + bulk_in_addr = bin; + bulk_out_addr = bout; + interrupt_in_addr = iin; + } + + let interface_number = mtp_interface_number + .ok_or_else(|| crate::Error::invalid_data("No MTP interface found on device"))?; + + let bulk_in_addr = + bulk_in_addr.ok_or_else(|| crate::Error::invalid_data("No bulk IN endpoint found"))?; + let bulk_out_addr = bulk_out_addr + .ok_or_else(|| crate::Error::invalid_data("No bulk OUT endpoint found"))?; + let interrupt_in_addr = interrupt_in_addr + .ok_or_else(|| crate::Error::invalid_data("No interrupt IN endpoint found"))?; + + // Claim the interface + // + // macOS: IOKit doesn't publish interface services for vendor-class / + // class-0 devices with no matching driver. Force-set configuration 1 + // so IOKit publishes them, then retry. + let interface = match device.claim_interface(interface_number).wait() { + Ok(iface) => iface, + #[cfg(target_os = "macos")] + Err(e) if Self::is_interface_unpublished(&e) => { + device + .set_configuration(1) + .wait() + .map_err(crate::Error::Usb)?; + device + .claim_interface(interface_number) + .wait() + .map_err(crate::Error::Usb)? + } + Err(e) => return Err(crate::Error::Usb(e)), + }; + + // Open endpoints + let bulk_in = interface + .endpoint::(bulk_in_addr) + .map_err(crate::Error::Usb)?; + let bulk_out = interface + .endpoint::(bulk_out_addr) + .map_err(crate::Error::Usb)?; + let interrupt_in = interface + .endpoint::(interrupt_in_addr) + .map_err(crate::Error::Usb)?; + + Ok(Self { + interface, + interface_number, + bulk_in: Mutex::new(bulk_in), + bulk_out: Mutex::new(bulk_out), + interrupt_in: Mutex::new(interrupt_in), + timeout, + }) + } + + /// Get the bulk transfer timeout. + #[must_use] + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Set the bulk transfer timeout. + pub fn set_timeout(&mut self, timeout: Duration) { + self.timeout = timeout; + } + + /// Convert a nusb TransferError to crate::Error. + fn convert_transfer_error(err: TransferError) -> crate::Error { + match err { + // send_bulk uses transfer_blocking, which cancels the transfer on + // timeout and returns Cancelled. Map to Timeout so that + // Error::is_retryable() treats it correctly. + TransferError::Cancelled => crate::Error::Timeout, + TransferError::Disconnected => crate::Error::Disconnected, + TransferError::Stall + | TransferError::Fault + | TransferError::InvalidArgument + | TransferError::Unknown(_) => crate::Error::Io(std::io::Error::other(err.to_string())), + } + } +} + +#[async_trait] +impl Transport for NusbTransport { + async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { + let mut ep = self.bulk_out.lock().await; + let buf: Buffer = data.to_vec().into(); + let completion = ep.transfer_blocking(buf, self.timeout); + completion.status.map_err(Self::convert_transfer_error)?; + Ok(()) + } + + async fn send_bulk_streaming(&self, chunks: BulkStream<'_>) -> Result<(), crate::Error> { + use futures::StreamExt; + + let mut ep = self.bulk_out.lock().await; + let max_packet_size = ep.max_packet_size(); + let transfer_size: usize = 256 * 1024; // 256KB per USB transfer + // Round up to max_packet_size boundary. + let transfer_size = (transfer_size.div_ceil(max_packet_size)).max(1) * max_packet_size; + + let mut current_buf = ep.allocate(transfer_size); + let mut total_sent = 0usize; + let mut stream = chunks; + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(crate::Error::Io)?; + let mut remaining = chunk.as_ref(); + + while !remaining.is_empty() { + let space = current_buf.remaining_capacity(); + let to_copy = remaining.len().min(space); + current_buf.extend_from_slice(&remaining[..to_copy]); + remaining = &remaining[to_copy..]; + + if current_buf.remaining_capacity() == 0 { + ep.submit(current_buf); + let completion = ep + .wait_next_complete(self.timeout) + .ok_or(crate::Error::Timeout)?; + completion.status.map_err(Self::convert_transfer_error)?; + total_sent += transfer_size; + current_buf = ep.allocate(transfer_size); + } + } + } + + // Send remaining data. + let final_len = current_buf.len(); + if final_len > 0 { + ep.submit(current_buf); + let completion = ep + .wait_next_complete(self.timeout) + .ok_or(crate::Error::Timeout)?; + completion.status.map_err(Self::convert_transfer_error)?; + + // If the final transfer was a multiple of max_packet_size, send ZLP + // so the device sees a short packet delimiter. + if final_len % max_packet_size == 0 { + ep.submit(Buffer::new(0)); + let completion = ep + .wait_next_complete(self.timeout) + .ok_or(crate::Error::Timeout)?; + completion.status.map_err(Self::convert_transfer_error)?; + } + } else if total_sent > 0 && total_sent % max_packet_size == 0 { + // All data fit in full transfers. Send ZLP to terminate. + ep.submit(Buffer::new(0)); + let completion = ep + .wait_next_complete(self.timeout) + .ok_or(crate::Error::Timeout)?; + completion.status.map_err(Self::convert_transfer_error)?; + } + + Ok(()) + } + + async fn receive_bulk(&self, max_size: usize) -> Result, crate::Error> { + let mut ep = self.bulk_in.lock().await; + + // If there's no pending transfer from a previous timed-out call, + // submit a new one. Otherwise, the pending transfer already has our + // data in flight and we just need to wait for it. + if ep.pending() == 0 { + let max_packet_size = ep.max_packet_size(); + let aligned_size = align_to_packet_size(max_size, max_packet_size); + ep.submit(Buffer::new(aligned_size)); + } + + // Wait for the transfer to complete OR the timeout to expire. + // next_complete() is cancel-safe: dropping its future does NOT cancel + // the underlying USB transfer. On timeout we leave the transfer pending + // so a subsequent call picks up the in-flight data. + let completion = futures::future::select( + Box::pin(ep.next_complete()), + Box::pin(Delay::new(self.timeout)), + ) + .await; + + match completion { + futures::future::Either::Left((comp, _)) => { + comp.status.map_err(Self::convert_transfer_error)?; + Ok(comp.buffer[..comp.actual_len].to_vec()) + } + futures::future::Either::Right((_, _)) => { + // Don't cancel the transfer — it stays pending in the endpoint. + // next_complete() is cancel-safe, so dropping its future is fine. + // On retry, the next call will find pending() > 0 and pick it up. + Err(crate::Error::Timeout) + } + } + } + + async fn receive_interrupt(&self) -> Result, crate::Error> { + let mut ep = self.interrupt_in.lock().await; + + // Submit a new transfer only if none is already pending. + if ep.pending() == 0 { + let max_packet_size = ep.max_packet_size(); + let aligned_size = align_to_packet_size(Self::INTERRUPT_BUFFER_SIZE, max_packet_size); + ep.submit(Buffer::new(aligned_size)); + } + + // Await indefinitely — callers handle cancellation via async + // cancellation (e.g. tokio::time::timeout or select!). + let completion = ep.next_complete().await; + completion.status.map_err(Self::convert_transfer_error)?; + Ok(completion.buffer[..completion.actual_len].to_vec()) + } + + /// USB Still Image Class cancel implementation. + /// + /// Getting mid-transfer cancellation right on MTP/PTP devices is notoriously + /// tricky. Different devices react differently to cancel signals, and getting + /// the timing wrong can leave the device unresponsive until physical replug. + /// + /// This implementation follows the approach proven by libmtp's + /// `ptp_read_cancel_func` (contributed by Florent Viard in 2017, PR #2), + /// which was developed through extensive real-device testing after discovering + /// that naive approaches caused Nexus 6P to fail all subsequent operations + /// and GoPro Hero 5 to freeze completely. + /// + /// The key insight: after sending the CLASS_CANCEL control request, the drain + /// must start *immediately*. The device stops sending new data quickly, but + /// data already in the USB pipeline must be read and discarded before the + /// pipe goes idle. Inserting any delay (like polling GET_DEVICE_STATUS, which + /// Android doesn't support anyway) causes the drain to find an empty pipe + /// while the device's MTP state machine remains stuck. + /// + /// The sequence is: + /// 1. Send CLASS_CANCEL control request (bRequest=0x64, 300ms timeout) + /// 2. Drain bulk IN pipe (read + discard until idle_timeout, maxpacket chunks) + /// 3. Drain interrupt pipe (consume CancelTransaction event if present) + /// + /// References: + /// - libmtp PR #2: + /// - USB Still Image Capture Device Definition, Section 5 + async fn cancel_transfer( + &self, + transaction_id: u32, + idle_timeout: Duration, + ) -> Result<(), crate::Error> { + // Step 1: Send CLASS_CANCEL control request (bRequest=0x64). + // + // 300ms timeout matches libmtp and Windows behavior. This is a + // class-specific control transfer on endpoint 0, independent of + // the bulk pipes. The 6-byte payload contains the CancelTransaction + // event code (0x4001) and the transaction ID to cancel. + let mut payload = [0u8; 6]; + payload[0..2].copy_from_slice(&SIC_CANCEL_EVENT_CODE.to_le_bytes()); + payload[2..6].copy_from_slice(&transaction_id.to_le_bytes()); + + self.interface + .control_out( + ControlOut { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: SIC_CANCEL_REQUEST, + value: 0, + index: self.interface_number as u16, + data: &payload, + }, + Duration::from_millis(300), + ) + .await + .map_err(Self::convert_transfer_error)?; + + // Step 2: Drain bulk IN pipe. + // + // Read and discard data in maxpacket-sized chunks until idle_timeout + // fires (no more data arriving). The device may still be sending data + // that was already in the USB pipeline when CLASS_CANCEL arrived. The + // drain also catches the Response container (Transaction_Cancelled or + // Ok) that the device sends to end the transaction. + // + // IMPORTANT: This must happen immediately after the control request. + // Any delay (like polling GET_DEVICE_STATUS) allows the device to + // give up waiting for us to read, leaving its MTP state machine stuck. + { + let mut ep = self.bulk_in.lock().await; + let max_packet_size = ep.max_packet_size(); + loop { + if ep.pending() == 0 { + let aligned_size = align_to_packet_size(max_packet_size, max_packet_size); + ep.submit(Buffer::new(aligned_size)); + } + + let drain_result = { + let complete_fut = ep.next_complete(); + let timeout_fut = Delay::new(idle_timeout); + futures::pin_mut!(complete_fut, timeout_fut); + + match futures::future::select(complete_fut, timeout_fut).await { + futures::future::Either::Left((completion, _)) => { + match completion.status { + Ok(()) => { + // Check for Response container (type code 3 at bytes [4..6]). + if completion.actual_len >= 6 { + let type_code = u16::from_le_bytes([ + completion.buffer[4], + completion.buffer[5], + ]); + if type_code == 3 { + Ok(true) // Response received, done + } else { + Ok(false) // Data, keep draining + } + } else { + Ok(false) // keep draining + } + } + Err(TransferError::Cancelled) => Ok(true), + Err(TransferError::Disconnected) => Err(crate::Error::Disconnected), + Err(_) => Ok(true), + } + } + futures::future::Either::Right((_, _)) => { + // Idle timeout — no more data arriving, pipe is clear. + Ok(true) + } + } + }; + + match drain_result { + Ok(true) => { + ep.cancel_all(); + while ep.pending() > 0 { + let _ = ep.next_complete().await; + } + break; + } + Ok(false) => continue, + Err(e) => return Err(e), + } + } + } + + // Step 3: Drain interrupt pipe. + // + // Consume the CancelTransaction event if the device sent one. This is + // critical for some devices — GoPro Hero 5 stops responding entirely + // if this event is left unread on the interrupt pipe. + { + let mut ep = self.interrupt_in.lock().await; + if ep.pending() == 0 { + let max_packet_size = ep.max_packet_size(); + let aligned_size = + align_to_packet_size(Self::INTERRUPT_BUFFER_SIZE, max_packet_size); + ep.submit(Buffer::new(aligned_size)); + } + + let timed_out = { + let complete_fut = ep.next_complete(); + let timeout_fut = Delay::new(idle_timeout); + futures::pin_mut!(complete_fut, timeout_fut); + + matches!( + futures::future::select(complete_fut, timeout_fut).await, + futures::future::Either::Right(_) + ) + }; + + if timed_out { + ep.cancel_all(); + while ep.pending() > 0 { + let _ = ep.next_complete().await; + } + } + } + Ok(()) + } +} + +/// Round `size` up to the nearest multiple of `packet_size`. +/// +/// nusb 0.2 requires that IN transfer buffer sizes are non-zero multiples of +/// the endpoint's maximum packet size. +fn align_to_packet_size(size: usize, packet_size: usize) -> usize { + if packet_size == 0 { + return size.max(1); + } + if size == 0 { + return packet_size; + } + if size % packet_size == 0 { + size + } else { + ((size / packet_size) + 1) * packet_size + } +} + +/// Derive a stable location identifier from USB topology (bus + port chain). +/// +/// Uses FNV-1a to hash `bus_id` and `port_chain` into a deterministic `u64`. +/// The result is stable across calls for the same physical USB port, regardless +/// of which device is plugged in. +fn location_id_from_topology(dev: &nusb::DeviceInfo) -> u64 { + // FNV-1a 64-bit constants + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0100_0000_01b3; + + let mut hash = FNV_OFFSET; + for byte in dev.bus_id().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + // Separator so bus_id "1" + port [2,3] differs from bus_id "12" + port [3] + hash ^= 0xFF; + hash = hash.wrapping_mul(FNV_PRIME); + for byte in dev.port_chain() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] // Requires real MTP device + fn test_list_devices() { + let devices = NusbTransport::list_mtp_devices().unwrap(); + println!("Found {} MTP devices", devices.len()); + for dev in &devices { + println!( + " {:04x}:{:04x} serial={:?} location={:08x}", + dev.vendor_id, dev.product_id, dev.serial_number, dev.location_id, + ); + } + } + + #[tokio::test] + #[ignore] // Requires real MTP device + async fn test_open_device() { + let devices = NusbTransport::list_mtp_devices().unwrap(); + assert!(!devices.is_empty(), "No MTP device found"); + + let device = devices[0].open().unwrap(); + let transport = NusbTransport::open(device).await.unwrap(); + + assert_eq!(transport.timeout(), NusbTransport::DEFAULT_TIMEOUT); + } + + #[tokio::test] + #[ignore] // Requires real MTP device + async fn test_timeout_configuration() { + let devices = NusbTransport::list_mtp_devices().unwrap(); + assert!(!devices.is_empty(), "No MTP device found"); + + let device = devices[0].open().unwrap(); + let custom_timeout = Duration::from_secs(60); + let mut transport = NusbTransport::open_with_timeout(device, custom_timeout) + .await + .unwrap(); + + assert_eq!(transport.timeout(), custom_timeout); + + // Test setter + let new_timeout = Duration::from_secs(10); + transport.set_timeout(new_timeout); + assert_eq!(transport.timeout(), new_timeout); + } + + #[test] + fn test_align_to_packet_size() { + // Zero size rounds up to packet_size + assert_eq!(align_to_packet_size(0, 512), 512); + // Size smaller than packet rounds up + assert_eq!(align_to_packet_size(1, 512), 512); + // Exact multiple stays the same + assert_eq!(align_to_packet_size(512, 512), 512); + assert_eq!(align_to_packet_size(1024, 512), 1024); + // Non-multiple rounds up + assert_eq!(align_to_packet_size(513, 512), 1024); + assert_eq!(align_to_packet_size(100, 64), 128); + // Zero packet_size edge case + assert_eq!(align_to_packet_size(0, 0), 1); + assert_eq!(align_to_packet_size(100, 0), 100); + } + + #[test] + fn test_mtp_class_detection() { + // Image class with MTP subclass/protocol + assert!(NusbTransport::is_mtp_class(0x06, 0x01, 0x01)); + + // Vendor class with MTP subclass/protocol + assert!(NusbTransport::is_mtp_class(0xFF, 0x01, 0x01)); + + // Wrong class + assert!(!NusbTransport::is_mtp_class(0x08, 0x01, 0x01)); + + // Wrong subclass + assert!(!NusbTransport::is_mtp_class(0x06, 0x00, 0x01)); + + // Wrong protocol + assert!(!NusbTransport::is_mtp_class(0x06, 0x01, 0x00)); + + // Vendor-specific with non-standard subclass/protocol (e.g. Kindle ff/ff/00) + assert!(!NusbTransport::is_mtp_class(0xFF, 0xFF, 0x00)); + } +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md b/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md new file mode 100644 index 0000000..b2dd4e2 --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md @@ -0,0 +1,48 @@ +# Virtual device transport + +Feature-gated (`virtual-device`) Transport implementation backed by local filesystem directories instead of USB. Exercises the full MTP/PTP binary protocol path. + +## Architecture + +``` +MtpDevice (unchanged) + → MtpDeviceInner (unchanged) + → PtpSession (unchanged) + → Arc + → VirtualTransport — implements Transport trait + → VirtualDeviceState — in-memory object tree + filesystem +``` + +## Module structure + +- `config.rs` — `VirtualDeviceConfig`, `VirtualStorageConfig` (public types) +- `state.rs` — `VirtualDeviceState`, `VirtualObject`, `PendingCommand`, handle management +- `builders.rs` — binary payload builders (DeviceInfo, StorageInfo, ObjectInfo, containers) +- `handlers.rs` — protocol operation handlers dispatched by opcode +- `registry.rs` — global virtual device registry for discovery integration (`list_devices`, `open_by_location`, `open_by_serial`) + active-state registry for `rescan_virtual_device()` and `pause_watcher()`/`WatcherGuard` +- `watcher.rs` — filesystem watcher for detecting out-of-band changes to backing directories +- `mod.rs` — `VirtualTransport` struct + `Transport` impl + tests + +## Key decisions + +- **`std::sync::Mutex`** over `parking_lot::Mutex`: `parking_lot` is only a dev-dep. Virtual transport isn't performance-critical, so std mutex is fine. +- **`PendingCommand` struct**: When the host sends a command that expects a data phase (SendObjectInfo, SendObject, SetObjectPropValue), the command is stored as a `PendingCommand` in `state.pending_command`. The next `send_bulk` (data container) takes it via `.take()` and dispatches both together. This keeps pending state separate from the response queue. +- **`VecDeque` for queues**: `response_queue` and `event_queue` use `VecDeque` for O(1) front removal (FIFO access pattern). +- **Discovery via global registry**: Virtual devices can be registered via `register_virtual_device()` to appear in `MtpDevice::list_devices()`. They get synthetic location IDs starting at `0xFFFF_0000_0000_0000` to avoid collisions with real USB devices. Uses `OnceLock` for the static registry. +- **Event poll interval**: `VirtualTransport` stores `event_poll_interval: Duration` outside the mutex. When no events are pending, `receive_interrupt` awaits this delay before returning `Timeout`, preventing CPU spin in event loops. Tests use `Duration::ZERO` for speed; production callers should use 50ms+. +- **Filesystem watcher**: Controlled by `VirtualDeviceConfig::watch_backing_dirs`. When `true`, a `notify::RecommendedWatcher` watches all backing dirs recursively. When files are written/deleted directly (bypassing MTP), the watcher detects changes and queues `ObjectAdded`/`ObjectRemoved` events. Gated behind `virtual-device` feature via the `notify` dependency. Tests that don't need the watcher should set this to `false` for faster startup and no background threads. +- **Watcher scope**: The filesystem watcher only tracks file/directory creation and removal. Content modifications to existing files are intentionally ignored — they don't change the object tree and would be noisy (editors write temp files, do atomic renames, etc.). Real MTP devices are also inconsistent about emitting `ObjectInfoChanged` for content edits. +- **Dedup for watcher events**: Uses state-based dedup rather than TTL tracking. MTP handlers modify the filesystem while holding the `state` mutex and insert/remove handles before releasing the lock. The watcher callback also acquires `state` before processing events. For creates, the watcher skips events when a handle already exists for the path. For removes, the watcher skips when no handle is found (already removed by the MTP handler). No extra tracking structure or timing assumptions needed. Events for the backing directory itself (empty relative path) are skipped — macOS FSEvents reports the watched directory as "created" on startup. +- **Canonical backing dirs**: `VirtualDeviceState::new()` canonicalizes all backing dirs at startup. This ensures consistent path comparison between handlers and the watcher callback (important on macOS where `/var` → `/private/var`). +- **Rescan via active-state registry**: `VirtualTransport::new()` registers its `Arc>` in a second global registry keyed by serial number. `rescan_virtual_device(serial)` looks up the state and calls `rescan_backing_dirs()`, which diffs the in-memory object tree against the filesystem, removing stale entries and adding new ones. The transport unregisters on drop. This avoids the fs watcher's latency (200-500ms on macOS FSEvents) and handles rapid delete+recreate sequences that the watcher can miss. +- **Watcher pause/resume**: `pause_watcher(serial)` returns a `WatcherGuard` (RAII) that sets `watcher_paused = true` on the device state. While paused, the watcher callback drops all events. The guard resumes on drop (poison-safe via `lock().ok()`). This prevents a race condition where external code deletes and recreates files in the backing directory: without pausing, the OS can deliver stale deletion events after a rescan has already re-added the objects. + +## Gotchas + +- `list_objects(None)` applies a parent filter (`ParentFilter::Exact(ROOT)`), so the virtual transport must set `parent = ObjectHandle::ROOT` on root-level objects for them to appear. +- `SendObjectInfo` for folders creates the directory immediately (no `SendObject` phase needed for folders per MTP spec). +- Storage IDs start at `0x00010001` (matching real MTP convention). +- The global registries (device registry + active-state registry) are process-wide and shared across tests. Registry tests must clean up with `unregister_virtual_device()` and use unique serial numbers to avoid interference. Rescan tests must also use unique serials. +- `event_poll_interval` lives on `VirtualTransport` (not inside `VirtualDeviceState`) because we need it after dropping the mutex lock and before an async `.await`. +- Fs watcher tests use canonicalized backing dirs and `poll_event_with_retry` to handle macOS FSEvents latency. The removal test drains create events first because macOS may coalesce or reorder events. +- `GetPartialObject64` (0x95C1) has a **different param layout** from `GetPartialObject` (0x101B): 4 params (handle, offset_lo, offset_hi, max_bytes) vs 3 (handle, offset, max_bytes). The two handlers share `read_partial()` for the actual file I/O but parse params separately. diff --git a/vendor/mtp-rs/src/transport/virtual_device/builders.rs b/vendor/mtp-rs/src/transport/virtual_device/builders.rs new file mode 100644 index 0000000..a3e497b --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/builders.rs @@ -0,0 +1,260 @@ +//! Binary payload builders for virtual device responses. +//! +//! These functions produce the binary payloads that get wrapped in data containers +//! before being returned to the PTP session. + +use super::state::VirtualDeviceState; +use crate::ptp::{ + pack_string, pack_u16, pack_u16_array, pack_u32, pack_u64, EventCode, ObjectFormatCode, + ObjectHandle, OperationCode, StorageId, +}; + +/// Build a DeviceInfo binary payload from the virtual device config. +pub(super) fn build_device_info(state: &VirtualDeviceState) -> Vec { + let mut buf = Vec::with_capacity(512); + + // StandardVersion: 100 (v1.00) + buf.extend_from_slice(&pack_u16(100)); + // VendorExtensionID: 6 (Microsoft / MTP) + buf.extend_from_slice(&pack_u32(6)); + // VendorExtensionVersion: 100 + buf.extend_from_slice(&pack_u16(100)); + // VendorExtensionDesc: "microsoft.com: 1.0" + buf.extend_from_slice(&pack_string("microsoft.com: 1.0")); + // FunctionalMode: 0 (standard) + buf.extend_from_slice(&pack_u16(0)); + + // OperationsSupported + let mut ops: Vec = vec![ + OperationCode::GetDeviceInfo.into(), + OperationCode::OpenSession.into(), + OperationCode::CloseSession.into(), + OperationCode::GetStorageIds.into(), + OperationCode::GetStorageInfo.into(), + OperationCode::GetObjectHandles.into(), + OperationCode::GetObjectInfo.into(), + OperationCode::GetObject.into(), + OperationCode::GetPartialObject.into(), + OperationCode::GetPartialObject64.into(), + OperationCode::GetThumb.into(), + OperationCode::SendObjectInfo.into(), + OperationCode::SendObject.into(), + OperationCode::DeleteObject.into(), + OperationCode::MoveObject.into(), + OperationCode::CopyObject.into(), + OperationCode::GetObjectPropValue.into(), + ]; + if state.config.supports_rename { + ops.push(OperationCode::SetObjectPropValue.into()); + } + buf.extend_from_slice(&pack_u16_array(&ops)); + + // EventsSupported + let events: Vec = vec![ + EventCode::ObjectAdded.into(), + EventCode::ObjectRemoved.into(), + EventCode::StorageInfoChanged.into(), + ]; + buf.extend_from_slice(&pack_u16_array(&events)); + + // DevicePropertiesSupported: empty + buf.extend_from_slice(&pack_u16_array(&[])); + // CaptureFormats: empty + buf.extend_from_slice(&pack_u16_array(&[])); + // PlaybackFormats: common formats + let playback: Vec = vec![ + ObjectFormatCode::Undefined.into(), + ObjectFormatCode::Association.into(), + ObjectFormatCode::Text.into(), + ObjectFormatCode::Jpeg.into(), + ObjectFormatCode::Png.into(), + ObjectFormatCode::Mp3.into(), + ObjectFormatCode::Mp4Container.into(), + ]; + buf.extend_from_slice(&pack_u16_array(&playback)); + + // Manufacturer + buf.extend_from_slice(&pack_string(&state.config.manufacturer)); + // Model + buf.extend_from_slice(&pack_string(&state.config.model)); + // DeviceVersion + buf.extend_from_slice(&pack_string("1.0.0")); + // SerialNumber + buf.extend_from_slice(&pack_string(&state.config.serial)); + + buf +} + +/// Build a StorageInfo binary payload for a given storage. +pub(super) fn build_storage_info( + state: &VirtualDeviceState, + storage_id: StorageId, +) -> Option> { + let storage = state.find_storage(storage_id)?; + let mut buf = Vec::with_capacity(128); + + // StorageType: FixedRam (3) + buf.extend_from_slice(&pack_u16(3)); + // FilesystemType: GenericHierarchical (2) + buf.extend_from_slice(&pack_u16(2)); + // AccessCapability + let access = if storage.config.read_only { 1u16 } else { 0u16 }; + buf.extend_from_slice(&pack_u16(access)); + // MaxCapacity + buf.extend_from_slice(&pack_u64(storage.config.capacity)); + // FreeSpaceInBytes - compute from backing dir + let free = compute_free_space(&storage.config); + buf.extend_from_slice(&pack_u64(free)); + // FreeSpaceInObjects: 0xFFFFFFFF (unknown) + buf.extend_from_slice(&pack_u32(0xFFFF_FFFF)); + // StorageDescription + buf.extend_from_slice(&pack_string(&storage.config.description)); + // VolumeIdentifier + buf.extend_from_slice(&pack_string("")); + + Some(buf) +} + +/// Compute free space for a storage by subtracting used space from capacity. +fn compute_free_space(config: &super::config::VirtualStorageConfig) -> u64 { + let used = dir_size(&config.backing_dir); + config.capacity.saturating_sub(used) +} + +/// Recursively compute total size of all files in a directory. +fn dir_size(path: &std::path::Path) -> u64 { + if !path.is_dir() { + return 0; + } + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if ft.is_file() { + total += entry.metadata().map(|m| m.len()).unwrap_or(0); + } else if ft.is_dir() { + total += dir_size(&entry.path()); + } + } + } + total +} + +/// Build a container header + params for a response container. +pub(super) fn build_response(code: u16, tx_id: u32, params: &[u32]) -> Vec { + let len = 12 + params.len() * 4; + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(3)); // Response type + buf.extend_from_slice(&pack_u16(code)); + buf.extend_from_slice(&pack_u32(tx_id)); + for &p in params { + buf.extend_from_slice(&pack_u32(p)); + } + buf +} + +/// Build a data container wrapping a payload. +pub(super) fn build_data_container(op_code: u16, tx_id: u32, payload: &[u8]) -> Vec { + let len = 12 + payload.len(); + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(2)); // Data type + buf.extend_from_slice(&pack_u16(op_code)); + buf.extend_from_slice(&pack_u32(tx_id)); + buf.extend_from_slice(payload); + buf +} + +/// Build an event container. +pub(super) fn build_event(code: EventCode, params: &[u32]) -> Vec { + let param_count = params.len().min(3); + let len = 12 + param_count * 4; + let mut buf = Vec::with_capacity(len); + buf.extend_from_slice(&pack_u32(len as u32)); + buf.extend_from_slice(&pack_u16(4)); // Event type + buf.extend_from_slice(&pack_u16(code.into())); + buf.extend_from_slice(&pack_u32(0)); // transaction_id = 0 for events + for &p in params.iter().take(param_count) { + buf.extend_from_slice(&pack_u32(p)); + } + buf +} + +/// Build an ObjectInfo payload from filesystem metadata. +pub(super) fn build_object_info( + handle: ObjectHandle, + state: &VirtualDeviceState, +) -> Option> { + let obj = state.objects.get(&handle.0)?; + let storage = state.find_storage(obj.storage_id)?; + let full_path = storage.config.backing_dir.join(&obj.rel_path); + + let metadata = std::fs::metadata(full_path).ok()?; + let filename = obj + .rel_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let is_dir = metadata.is_dir(); + let size = if is_dir { + 0u32 + } else { + metadata.len().min(u32::MAX as u64) as u32 + }; + let format: u16 = if is_dir { + ObjectFormatCode::Association.into() + } else { + let ext = obj + .rel_path + .extension() + .unwrap_or_default() + .to_string_lossy(); + ObjectFormatCode::from_extension(&ext).into() + }; + + let mut buf = Vec::with_capacity(128); + + // StorageID + buf.extend_from_slice(&pack_u32(obj.storage_id.0)); + // ObjectFormat + buf.extend_from_slice(&pack_u16(format)); + // ProtectionStatus: None (0) + buf.extend_from_slice(&pack_u16(0)); + // ObjectCompressedSize + buf.extend_from_slice(&pack_u32(size)); + // ThumbFormat: Undefined + buf.extend_from_slice(&pack_u16(ObjectFormatCode::Undefined.into())); + // ThumbCompressedSize, ThumbPixWidth, ThumbPixHeight + buf.extend_from_slice(&pack_u32(0)); + buf.extend_from_slice(&pack_u32(0)); + buf.extend_from_slice(&pack_u32(0)); + // ImagePixWidth, ImagePixHeight, ImageBitDepth + buf.extend_from_slice(&pack_u32(0)); + buf.extend_from_slice(&pack_u32(0)); + buf.extend_from_slice(&pack_u32(0)); + // ParentObject + buf.extend_from_slice(&pack_u32(obj.parent.0)); + // AssociationType + let assoc_type: u16 = if is_dir { 1 } else { 0 }; + buf.extend_from_slice(&pack_u16(assoc_type)); + // AssociationDesc + buf.extend_from_slice(&pack_u32(0)); + // SequenceNumber + buf.extend_from_slice(&pack_u32(0)); + // Filename + buf.extend_from_slice(&pack_string(&filename)); + // DateCreated: empty + buf.push(0x00); + // DateModified: empty + buf.push(0x00); + // Keywords: empty + buf.push(0x00); + + Some(buf) +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/config.rs b/vendor/mtp-rs/src/transport/virtual_device/config.rs new file mode 100644 index 0000000..fded25f --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/config.rs @@ -0,0 +1,69 @@ +//! Configuration types for virtual MTP devices. + +use std::path::PathBuf; +use std::time::Duration; + +/// Configuration for a virtual MTP device. +/// +/// Defines the identity and storages of a virtual device that operates against +/// a local filesystem directory instead of real USB hardware. +/// +/// # Example +/// +/// ```rust +/// use std::path::PathBuf; +/// use std::time::Duration; +/// use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; +/// +/// let config = VirtualDeviceConfig { +/// manufacturer: "Google".into(), +/// model: "Virtual Pixel 9".into(), +/// serial: "virtual-001".into(), +/// storages: vec![VirtualStorageConfig { +/// description: "Internal Storage".into(), +/// capacity: 64 * 1024 * 1024 * 1024, +/// backing_dir: PathBuf::from("/tmp/mtp-test"), +/// read_only: false, +/// }], +/// supports_rename: true, +/// event_poll_interval: Duration::from_millis(50), +/// watch_backing_dirs: true, +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct VirtualDeviceConfig { + /// Manufacturer name reported by the virtual device. + pub manufacturer: String, + /// Model name reported by the virtual device. + pub model: String, + /// Serial number for the virtual device. + pub serial: String, + /// Storage configurations. At least one storage is required. + pub storages: Vec, + /// Whether the device advertises SetObjectPropValue support (rename). + pub supports_rename: bool, + /// How long `receive_interrupt` waits when no events are pending. + /// Simulates the USB interrupt endpoint blocking behavior. + /// Default: 50ms for production use. Use `Duration::ZERO` in tests + /// to avoid slowing down the test suite. + pub event_poll_interval: Duration, + /// Watch backing directories for out-of-band changes and emit MTP events. + /// When `true`, a background filesystem watcher detects files created or + /// removed directly in the backing directories (bypassing MTP) and queues + /// `ObjectAdded`/`ObjectRemoved` events. Set to `false` for tests that + /// don't need this (faster startup, no background threads). + pub watch_backing_dirs: bool, +} + +/// Configuration for a single storage within a virtual device. +#[derive(Debug, Clone)] +pub struct VirtualStorageConfig { + /// Human-readable storage description (for example, "Internal Storage"). + pub description: String, + /// Maximum storage capacity in bytes. + pub capacity: u64, + /// Local directory backing this storage. Files here become MTP objects. + pub backing_dir: PathBuf, + /// If true, write operations return `StoreReadOnly`. + pub read_only: bool, +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/handlers.rs b/vendor/mtp-rs/src/transport/virtual_device/handlers.rs new file mode 100644 index 0000000..85d65bc --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/handlers.rs @@ -0,0 +1,1096 @@ +//! Protocol operation handlers for the virtual device. +//! +//! Each handler processes an incoming MTP command and queues response (and optionally +//! data) containers into the state's response queue. + +use super::builders::{ + build_data_container, build_device_info, build_event, build_object_info, build_response, + build_storage_info, +}; +use super::state::{PendingSendInfo, VirtualDeviceState}; +use crate::ptp::{ + pack_string, pack_u32_array, unpack_string, EventCode, ObjectFormatCode, ObjectHandle, + ObjectPropertyCode, OperationCode, StorageId, +}; +use std::path::{Path, PathBuf}; + +/// Response code constants as u16. +const OK: u16 = 0x2001; +const GENERAL_ERROR: u16 = 0x2002; +const SESSION_NOT_OPEN: u16 = 0x2003; +const OPERATION_NOT_SUPPORTED: u16 = 0x2005; +const INVALID_STORAGE_ID: u16 = 0x2008; +const INVALID_OBJECT_HANDLE: u16 = 0x2009; +const STORE_READ_ONLY: u16 = 0x200E; +const NO_THUMBNAIL: u16 = 0x2010; +const SESSION_ALREADY_OPEN: u16 = 0x201E; +const INVALID_PARENT: u16 = 0x201A; + +/// Dispatch a command to the appropriate handler. +/// +/// `op_code` is the raw operation code, `tx_id` the transaction ID, +/// `params` are the u32 command parameters, and `data_payload` is the +/// optional data phase payload (for operations like SendObjectInfo/SendObject). +pub(super) fn dispatch( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], + data_payload: Option<&[u8]>, +) { + let op = OperationCode::from(op_code); + + match op { + OperationCode::GetDeviceInfo => handle_get_device_info(state, op_code, tx_id), + OperationCode::OpenSession => handle_open_session(state, tx_id, params), + OperationCode::CloseSession => handle_close_session(state, tx_id), + _ => { + // All other operations require an open session + if !state.session_open { + state + .response_queue + .push_back(build_response(SESSION_NOT_OPEN, tx_id, &[])); + return; + } + match op { + OperationCode::GetStorageIds => handle_get_storage_ids(state, op_code, tx_id), + OperationCode::GetStorageInfo => { + handle_get_storage_info(state, op_code, tx_id, params) + } + OperationCode::GetObjectHandles => { + handle_get_object_handles(state, op_code, tx_id, params) + } + OperationCode::GetObjectInfo => { + handle_get_object_info(state, op_code, tx_id, params) + } + OperationCode::GetObject => handle_get_object(state, op_code, tx_id, params), + OperationCode::GetPartialObject => { + handle_get_partial_object(state, op_code, tx_id, params) + } + OperationCode::GetPartialObject64 => { + handle_get_partial_object_64(state, op_code, tx_id, params) + } + OperationCode::GetThumb => handle_get_thumb(state, tx_id, params), + OperationCode::SendObjectInfo => { + handle_send_object_info(state, tx_id, params, data_payload) + } + OperationCode::SendObject => handle_send_object(state, tx_id, data_payload), + OperationCode::DeleteObject => handle_delete_object(state, tx_id, params), + OperationCode::MoveObject => handle_move_object(state, tx_id, params), + OperationCode::CopyObject => handle_copy_object(state, tx_id, params), + OperationCode::GetObjectPropValue => { + handle_get_object_prop_value(state, op_code, tx_id, params) + } + OperationCode::SetObjectPropValue => { + handle_set_object_prop_value(state, tx_id, params, data_payload) + } + _ => { + state.response_queue.push_back(build_response( + OPERATION_NOT_SUPPORTED, + tx_id, + &[], + )); + } + } + } + } +} + +fn handle_get_device_info(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32) { + let payload = build_device_info(state); + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_open_session(state: &mut VirtualDeviceState, tx_id: u32, _params: &[u32]) { + if state.session_open { + state + .response_queue + .push_back(build_response(SESSION_ALREADY_OPEN, tx_id, &[])); + } else { + state.session_open = true; + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } +} + +fn handle_close_session(state: &mut VirtualDeviceState, tx_id: u32) { + state.session_open = false; + state.objects.clear(); + state.pending_send = None; + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_get_storage_ids(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32) { + let ids: Vec = state.storages.iter().map(|s| s.storage_id.0).collect(); + let payload = pack_u32_array(&ids); + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_get_storage_info( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let storage_id = StorageId(params.first().copied().unwrap_or(0)); + match build_storage_info(state, storage_id) { + Some(payload) => { + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + None => { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + } + } +} + +fn handle_get_object_handles( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let storage_id = StorageId(params.first().copied().unwrap_or(0)); + // params[1] = format filter (ignored), params[2] = parent handle + let parent_raw = params.get(2).copied().unwrap_or(0); + let parent = ObjectHandle(parent_raw); + + // Validate storage + if state.find_storage(storage_id).is_none() { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + return; + } + + let handles_result = if parent == ObjectHandle::ALL { + state.scan_all(storage_id) + } else { + state.scan_dir(storage_id, parent) + }; + + match handles_result { + Ok(handles) => { + let raw: Vec = handles.iter().map(|h| h.0).collect(); + let payload = pack_u32_array(&raw); + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +fn handle_get_object_info( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + match build_object_info(handle, state) { + Some(payload) => { + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + } + } +} + +fn handle_get_object(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32, params: &[u32]) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + match std::fs::read(path) { + Ok(data) => { + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &data)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +fn handle_get_partial_object( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let offset = params.get(1).copied().unwrap_or(0) as u64; + let max_bytes = params.get(2).copied().unwrap_or(0) as usize; + read_partial(state, op_code, tx_id, handle, offset, max_bytes); +} + +fn handle_get_partial_object_64( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let offset_lo = params.get(1).copied().unwrap_or(0) as u64; + let offset_hi = params.get(2).copied().unwrap_or(0) as u64; + let offset = (offset_hi << 32) | offset_lo; + let max_bytes = params.get(3).copied().unwrap_or(0) as usize; + read_partial(state, op_code, tx_id, handle, offset, max_bytes); +} + +fn read_partial( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + handle: ObjectHandle, + offset: u64, + max_bytes: usize, +) { + use std::io::{Read, Seek, SeekFrom}; + + let path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + match std::fs::File::open(path) { + Ok(mut file) => { + if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + let mut buf = vec![0u8; max_bytes]; + match file.read(&mut buf) { + Ok(n) => { + buf.truncate(n); + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &buf)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +fn handle_get_thumb(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + if !state.objects.contains_key(&handle.0) { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + } else { + // Virtual device never has thumbnails + state + .response_queue + .push_back(build_response(NO_THUMBNAIL, tx_id, &[])); + } +} + +fn handle_send_object_info( + state: &mut VirtualDeviceState, + tx_id: u32, + params: &[u32], + data_payload: Option<&[u8]>, +) { + let storage_id = StorageId(params.first().copied().unwrap_or(0)); + let parent = ObjectHandle(params.get(1).copied().unwrap_or(0)); + + // Validate storage + if state.find_storage(storage_id).is_none() { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + return; + } + + // Check read-only + if state.is_read_only(storage_id) { + state + .response_queue + .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); + return; + } + + // Validate parent (must be ROOT or a known directory handle in this storage) + if parent != ObjectHandle::ROOT && parent.0 != 0 { + match state.objects.get(&parent.0) { + Some(obj) if obj.storage_id == storage_id => { + let path = state.resolve_path(parent).unwrap(); + if !path.is_dir() { + state + .response_queue + .push_back(build_response(INVALID_PARENT, tx_id, &[])); + return; + } + } + _ => { + state + .response_queue + .push_back(build_response(INVALID_PARENT, tx_id, &[])); + return; + } + } + } + + // Parse ObjectInfo from data payload + let data = match data_payload { + Some(d) => d, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + // Parse just what we need: skip to filename at offset 52 + let info = match crate::ptp::ObjectInfo::from_bytes(data) { + Ok(i) => i, + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let is_folder = info.format == ObjectFormatCode::Association; + let new_handle = state.alloc_handle(); + + state.pending_send = Some(PendingSendInfo { + storage_id, + parent, + filename: info.filename.clone(), + size: info.size, + is_folder, + assigned_handle: new_handle, + }); + + // For folders, create immediately (no SendObject phase needed) + if is_folder { + let storage = state.find_storage(storage_id).unwrap(); + let parent_path = if parent == ObjectHandle::ROOT || parent.0 == 0 { + PathBuf::new() + } else { + state.objects.get(&parent.0).unwrap().rel_path.clone() + }; + let rel_path = parent_path.join(&info.filename); + let full_path = storage.config.backing_dir.join(&rel_path); + + if validate_path_within(&storage.config.backing_dir, &full_path).is_err() { + state.pending_send = None; + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + if std::fs::create_dir_all(&full_path).is_err() { + state.pending_send = None; + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + state.objects.insert( + new_handle.0, + super::state::VirtualObject { + rel_path, + storage_id, + parent, + }, + ); + + // Queue events + state + .event_queue + .push_back(build_event(EventCode::ObjectAdded, &[new_handle.0])); + state + .event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); + + state.pending_send = None; + } + + // Response params: storage_id, parent_handle, new_object_handle + state.response_queue.push_back(build_response( + OK, + tx_id, + &[storage_id.0, parent.0, new_handle.0], + )); +} + +fn handle_send_object(state: &mut VirtualDeviceState, tx_id: u32, data_payload: Option<&[u8]>) { + let pending = match state.pending_send.take() { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + if pending.is_folder { + // Folder was already created in SendObjectInfo; SendObject is a no-op + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + return; + } + + let data = data_payload.unwrap_or(&[]); + + let storage = match state.find_storage(pending.storage_id) { + Some(s) => s, + None => { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + return; + } + }; + + let parent_path = if pending.parent == ObjectHandle::ROOT || pending.parent.0 == 0 { + PathBuf::new() + } else { + match state.objects.get(&pending.parent.0) { + Some(obj) => obj.rel_path.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_PARENT, tx_id, &[])); + return; + } + } + }; + + let rel_path = parent_path.join(&pending.filename); + let full_path = storage.config.backing_dir.join(&rel_path); + + if validate_path_within(&storage.config.backing_dir, &full_path).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + // Ensure parent directory exists + if let Some(parent_dir) = full_path.parent() { + if std::fs::create_dir_all(parent_dir).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + } + + if std::fs::write(&full_path, data).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + let handle = pending.assigned_handle; + state.objects.insert( + handle.0, + super::state::VirtualObject { + rel_path, + storage_id: pending.storage_id, + parent: pending.parent, + }, + ); + + // Queue events + state + .event_queue + .push_back(build_event(EventCode::ObjectAdded, &[handle.0])); + state.event_queue.push_back(build_event( + EventCode::StorageInfoChanged, + &[pending.storage_id.0], + )); + + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_delete_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + + let obj = match state.objects.get(&handle.0) { + Some(o) => o.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + if state.is_read_only(obj.storage_id) { + state + .response_queue + .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); + return; + } + + let path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let result = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + + match result { + Ok(()) => { + let storage_id = obj.storage_id; + + // Remove this handle and any child handles + let to_remove: Vec = state + .objects + .iter() + .filter(|(_, o)| { + o.storage_id == storage_id && (o.rel_path.starts_with(&obj.rel_path)) + }) + .map(|(&h, _)| h) + .collect(); + + for h in &to_remove { + state.objects.remove(h); + } + + state + .event_queue + .push_back(build_event(EventCode::ObjectRemoved, &[handle.0])); + state + .event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); + + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +fn handle_move_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let dest_storage = StorageId(params.get(1).copied().unwrap_or(0)); + let dest_parent = ObjectHandle(params.get(2).copied().unwrap_or(0)); + + let obj = match state.objects.get(&handle.0) { + Some(o) => o.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + if state.is_read_only(obj.storage_id) || state.is_read_only(dest_storage) { + state + .response_queue + .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); + return; + } + + let src_path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let dest_storage_state = match state.find_storage(dest_storage) { + Some(s) => s, + None => { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + return; + } + }; + + let filename = obj.rel_path.file_name().unwrap_or_default().to_os_string(); + + let dest_parent_rel = if dest_parent == ObjectHandle::ROOT || dest_parent.0 == 0 { + PathBuf::new() + } else { + match state.objects.get(&dest_parent.0) { + Some(o) => o.rel_path.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_PARENT, tx_id, &[])); + return; + } + } + }; + + let new_rel = dest_parent_rel.join(filename); + let dest_path = dest_storage_state.config.backing_dir.join(&new_rel); + + if validate_path_within(&dest_storage_state.config.backing_dir, &dest_path).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + if std::fs::rename(&src_path, &dest_path).is_err() { + // Cross-device move: copy + delete + let copy_result = if src_path.is_dir() { + copy_dir_all(&src_path, &dest_path) + } else { + std::fs::copy(&src_path, &dest_path).map(|_| ()) + }; + match copy_result { + Ok(()) => { + let _ = if src_path.is_dir() { + std::fs::remove_dir_all(&src_path) + } else { + std::fs::remove_file(&src_path) + }; + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + } + } + + // Update object's storage and path + let src_storage = obj.storage_id; + if let Some(obj) = state.objects.get_mut(&handle.0) { + obj.rel_path = new_rel; + obj.storage_id = dest_storage; + obj.parent = dest_parent; + } + + state + .event_queue + .push_back(build_event(EventCode::ObjectInfoChanged, &[handle.0])); + state + .event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[src_storage.0])); + if dest_storage != src_storage { + state.event_queue.push_back(build_event( + EventCode::StorageInfoChanged, + &[dest_storage.0], + )); + } + + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_copy_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let dest_storage = StorageId(params.get(1).copied().unwrap_or(0)); + let dest_parent = ObjectHandle(params.get(2).copied().unwrap_or(0)); + + let obj = match state.objects.get(&handle.0) { + Some(o) => o.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + if state.is_read_only(dest_storage) { + state + .response_queue + .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); + return; + } + + let src_path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let dest_storage_state = match state.find_storage(dest_storage) { + Some(s) => s, + None => { + state + .response_queue + .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); + return; + } + }; + + let filename = obj.rel_path.file_name().unwrap_or_default().to_os_string(); + + let dest_parent_rel = if dest_parent == ObjectHandle::ROOT || dest_parent.0 == 0 { + PathBuf::new() + } else { + match state.objects.get(&dest_parent.0) { + Some(o) => o.rel_path.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_PARENT, tx_id, &[])); + return; + } + } + }; + + let new_rel = dest_parent_rel.join(filename); + let dest_path = dest_storage_state.config.backing_dir.join(&new_rel); + + if validate_path_within(&dest_storage_state.config.backing_dir, &dest_path).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + let copy_result = if src_path.is_dir() { + copy_dir_all(&src_path, &dest_path) + } else { + std::fs::copy(&src_path, &dest_path).map(|_| ()) + }; + + match copy_result { + Ok(()) => { + let new_handle = state.alloc_handle(); + state.objects.insert( + new_handle.0, + super::state::VirtualObject { + rel_path: new_rel, + storage_id: dest_storage, + parent: dest_parent, + }, + ); + + state + .event_queue + .push_back(build_event(EventCode::ObjectAdded, &[new_handle.0])); + state.event_queue.push_back(build_event( + EventCode::StorageInfoChanged, + &[dest_storage.0], + )); + + state + .response_queue + .push_back(build_response(OK, tx_id, &[new_handle.0])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +fn handle_get_object_prop_value( + state: &mut VirtualDeviceState, + op_code: u16, + tx_id: u32, + params: &[u32], +) { + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let prop_code = params.get(1).copied().unwrap_or(0) as u16; + let property = ObjectPropertyCode::from(prop_code); + + let obj = match state.objects.get(&handle.0) { + Some(o) => o.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + let payload = match property { + ObjectPropertyCode::ObjectFileName => { + let name = obj + .rel_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + pack_string(&name) + } + ObjectPropertyCode::StorageId => crate::ptp::pack_u32(obj.storage_id.0).to_vec(), + ObjectPropertyCode::ParentObject => crate::ptp::pack_u32(obj.parent.0).to_vec(), + ObjectPropertyCode::ObjectSize => { + let path = state.resolve_path(handle).unwrap(); + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + crate::ptp::pack_u64(size).to_vec() + } + _ => { + state + .response_queue + .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); + return; + } + }; + + state + .response_queue + .push_back(build_data_container(op_code, tx_id, &payload)); + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); +} + +fn handle_set_object_prop_value( + state: &mut VirtualDeviceState, + tx_id: u32, + params: &[u32], + data_payload: Option<&[u8]>, +) { + if !state.config.supports_rename { + state + .response_queue + .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); + return; + } + + let handle = ObjectHandle(params.first().copied().unwrap_or(0)); + let prop_code = params.get(1).copied().unwrap_or(0) as u16; + let property = ObjectPropertyCode::from(prop_code); + + if property != ObjectPropertyCode::ObjectFileName { + state + .response_queue + .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); + return; + } + + let data = match data_payload { + Some(d) => d, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let (new_name, _) = match unpack_string(data) { + Ok(v) => v, + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let obj = match state.objects.get(&handle.0) { + Some(o) => o.clone(), + None => { + state + .response_queue + .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); + return; + } + }; + + if state.is_read_only(obj.storage_id) { + state + .response_queue + .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); + return; + } + + let old_path = match state.resolve_path(handle) { + Some(p) => p, + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + + let new_path = old_path.parent().unwrap().join(&new_name); + + // Find the backing dir for this object's storage + let backing_dir = match state.find_storage(obj.storage_id) { + Some(s) => s.config.backing_dir.clone(), + None => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + }; + if validate_path_within(&backing_dir, &new_path).is_err() { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + return; + } + + match std::fs::rename(&old_path, &new_path) { + Ok(()) => { + // Update the relative path + if let Some(obj_mut) = state.objects.get_mut(&handle.0) { + let parent_rel = obj_mut.rel_path.parent().map(|p| p.to_path_buf()); + obj_mut.rel_path = match parent_rel { + Some(p) if p != PathBuf::new() => p.join(&new_name), + _ => PathBuf::from(&new_name), + }; + } + + state + .response_queue + .push_back(build_response(OK, tx_id, &[])); + } + Err(_) => { + state + .response_queue + .push_back(build_response(GENERAL_ERROR, tx_id, &[])); + } + } +} + +/// Check that `path` is inside `base_dir`. Returns the canonicalized path or an error. +fn validate_path_within(base_dir: &Path, path: &Path) -> Result { + let canonical_base = base_dir.canonicalize().map_err(|_| ())?; + + // If the path exists, canonicalize it directly + if path.exists() { + let canonical = path.canonicalize().map_err(|_| ())?; + if canonical.starts_with(&canonical_base) { + return Ok(canonical); + } + return Err(()); + } + + // Path doesn't exist yet — canonicalize its parent and check + if let Some(parent) = path.parent() { + if parent.exists() { + let canonical_parent = parent.canonicalize().map_err(|_| ())?; + if canonical_parent.starts_with(&canonical_base) { + let filename = path.file_name().ok_or(())?; + // Also reject filenames containing path separators or .. + let name_str = filename.to_string_lossy(); + if name_str.contains("..") || name_str.contains('/') || name_str.contains('\\') { + return Err(()); + } + return Ok(canonical_parent.join(filename)); + } + } + } + Err(()) +} + +/// Recursively copy a directory. +fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ft = entry.file_type()?; + let dest_path = dst.join(entry.file_name()); + if ft.is_dir() { + copy_dir_all(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), &dest_path)?; + } + } + Ok(()) +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/mod.rs b/vendor/mtp-rs/src/transport/virtual_device/mod.rs new file mode 100644 index 0000000..f8d8f5e --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/mod.rs @@ -0,0 +1,1290 @@ +//! Virtual MTP device transport for testing. +//! +//! This module provides a [`VirtualTransport`] that implements the [`Transport`] trait +//! using a local filesystem directory as its backing store. It speaks the full MTP/PTP +//! binary protocol, so the existing `PtpSession`, `MtpDevice`, and `Storage` types +//! work unchanged. +//! +//! Use this to test MTP client code without real USB hardware. +//! +//! # Example +//! +//! ```rust,no_run +//! use std::path::PathBuf; +//! use std::time::Duration; +//! use mtp_rs::MtpDevice; +//! use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; +//! +//! # async fn example() -> Result<(), mtp_rs::Error> { +//! let device = MtpDevice::builder() +//! .open_virtual(VirtualDeviceConfig { +//! manufacturer: "Google".into(), +//! model: "Virtual Pixel 9".into(), +//! serial: "virtual-001".into(), +//! storages: vec![VirtualStorageConfig { +//! description: "Internal Storage".into(), +//! capacity: 64 * 1024 * 1024 * 1024, +//! backing_dir: PathBuf::from("/tmp/mtp-test"), +//! read_only: false, +//! }], +//! supports_rename: true, +//! event_poll_interval: Duration::from_millis(50), +//! watch_backing_dirs: true, +//! }) +//! .await?; +//! +//! // Use the device exactly like a real one +//! for storage in device.storages().await? { +//! for obj in storage.list_objects(None).await? { +//! println!("{}", obj.filename); +//! } +//! } +//! # Ok(()) +//! # } +//! ``` + +mod builders; +pub mod config; +mod handlers; +pub mod registry; +mod state; +mod watcher; + +use crate::ptp::{unpack_u16, unpack_u32}; +use crate::transport::Transport; +use async_trait::async_trait; +use config::VirtualDeviceConfig; +pub use state::RescanSummary; +use state::{PendingCommand, VirtualDeviceState}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// A transport that speaks MTP/PTP binary protocol against a local filesystem. +/// +/// Created via `MtpDeviceBuilder::open_virtual()` or directly for lower-level use. +/// +/// Internally, incoming `send_bulk` calls are parsed as MTP command/data containers. +/// The virtual device processes each operation (list files, read, write, delete, etc.) +/// against the configured backing directories and queues binary response containers +/// for the next `receive_bulk` call. +/// +/// A background filesystem watcher detects out-of-band changes to the backing +/// directories and queues corresponding MTP events. The watcher is stopped +/// automatically when the transport is dropped. +pub struct VirtualTransport { + state: Arc>, + /// How long `receive_interrupt` waits when no events are pending. + event_poll_interval: Duration, + /// Serial number, used to unregister from the active-states registry on drop. + serial: String, + /// Filesystem watcher. Stops watching when dropped. + _watcher: Option, +} + +impl VirtualTransport { + /// Create a new virtual transport from a device configuration. + /// + /// The backing directories in each storage config should already exist. + /// When `config.watch_backing_dirs` is `true`, starts a background + /// filesystem watcher for detecting out-of-band changes. + #[must_use] + pub fn new(config: VirtualDeviceConfig) -> Self { + let event_poll_interval = config.event_poll_interval; + let watch = config.watch_backing_dirs; + let serial = config.serial.clone(); + let state = Arc::new(Mutex::new(VirtualDeviceState::new(config))); + + // Register the state so `rescan_virtual_device()` can find it. + registry::register_active_state(serial.clone(), Arc::clone(&state)); + + let watcher = if watch { + watcher::start_fs_watcher(&state) + } else { + None + }; + Self { + state, + event_poll_interval, + serial, + _watcher: watcher, + } + } +} + +impl Drop for VirtualTransport { + fn drop(&mut self) { + registry::unregister_active_state(&self.serial); + } +} + +/// Container type constants. +const CONTAINER_TYPE_COMMAND: u16 = 1; +const CONTAINER_TYPE_DATA: u16 = 2; + +#[async_trait] +impl Transport for VirtualTransport { + async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { + if data.len() < 12 { + return Err(crate::Error::invalid_data("container too small")); + } + + let _length = unpack_u32(&data[0..4])?; + let container_type = unpack_u16(&data[4..6])?; + let code = unpack_u16(&data[6..8])?; + let tx_id = unpack_u32(&data[8..12])?; + + let mut state = self.state.lock().unwrap(); + + match container_type { + CONTAINER_TYPE_COMMAND => { + // Parse parameters (each u32, after the 12-byte header) + let param_bytes = data.len() - 12; + let param_count = param_bytes / 4; + let mut params = Vec::with_capacity(param_count); + for i in 0..param_count { + let offset = 12 + i * 4; + params.push(unpack_u32(&data[offset..])?); + } + + // Check if this operation expects a data phase from the host. + // If so, don't dispatch yet -- store the command and wait for data. + let op = crate::ptp::OperationCode::from(code); + if matches!( + op, + crate::ptp::OperationCode::SendObjectInfo + | crate::ptp::OperationCode::SendObject + | crate::ptp::OperationCode::SetObjectPropValue + ) { + state.pending_command = Some(PendingCommand { + code, + tx_id, + params, + }); + } else { + handlers::dispatch(&mut state, code, tx_id, ¶ms, None); + } + } + CONTAINER_TYPE_DATA => { + // This is the data phase for a previous command. + match state.pending_command.take() { + Some(pending) => { + let payload = &data[12..]; // Skip data container header + handlers::dispatch( + &mut state, + pending.code, + pending.tx_id, + &pending.params, + Some(payload), + ); + } + None => { + return Err(crate::Error::invalid_data( + "received data container without pending command", + )); + } + } + } + _ => { + return Err(crate::Error::invalid_data(format!( + "unexpected container type: {}", + container_type + ))); + } + } + + Ok(()) + } + + async fn receive_bulk(&self, _max_size: usize) -> Result, crate::Error> { + let mut state = self.state.lock().unwrap(); + match state.response_queue.pop_front() { + Some(data) => Ok(data), + None => Err(crate::Error::invalid_data("no response available")), + } + } + + async fn receive_interrupt(&self) -> Result, crate::Error> { + // Check for events (drop lock before any await) + { + let mut state = self.state.lock().unwrap(); + if let Some(event) = state.event_queue.pop_front() { + return Ok(event); + } + } + // No events — wait, then return Timeout + futures_timer::Delay::new(self.event_poll_interval).await; + Err(crate::Error::Timeout) + } + + async fn cancel_transfer( + &self, + _transaction_id: u32, + _idle_timeout: std::time::Duration, + ) -> Result<(), crate::Error> { + // Virtual device has no USB pipe to drain — just clear any pending state. + let mut state = self.state.lock().unwrap(); + state.pending_command = None; + state.response_queue.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::config::{VirtualDeviceConfig, VirtualStorageConfig}; + use crate::mtp::MtpDevice; + use crate::ptp::ObjectFormatCode; + use std::time::Duration; + + fn test_config(dir: &std::path::Path) -> VirtualDeviceConfig { + VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-001".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, // 1 GB + backing_dir: dir.to_path_buf(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + } + } + + fn test_config_readonly(dir: &std::path::Path) -> VirtualDeviceConfig { + VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-ro".into(), + storages: vec![VirtualStorageConfig { + description: "Read-only Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: dir.to_path_buf(), + read_only: true, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + } + } + + fn test_config_multi(dirs: &[&std::path::Path]) -> VirtualDeviceConfig { + VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-multi".into(), + storages: dirs + .iter() + .enumerate() + .map(|(i, d)| VirtualStorageConfig { + description: format!("Storage {}", i + 1), + capacity: 1024 * 1024 * 1024, + backing_dir: d.to_path_buf(), + read_only: false, + }) + .collect(), + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + } + } + + /// Helper to convert `&[u8]` to a `Stream>`. + fn bytes_stream( + data: &[u8], + ) -> impl futures::Stream> + Unpin { + futures::stream::once(futures::future::ok(bytes::Bytes::copy_from_slice(data))) + } + + #[tokio::test] + async fn open_virtual_and_list_storages() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + assert_eq!(storages.len(), 1); + assert_eq!(storages[0].info().description, "Internal Storage"); + assert!(storages[0].info().max_capacity > 0); + } + + #[tokio::test] + async fn device_info_matches_config() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let info = device.device_info(); + + assert_eq!(info.manufacturer, "TestCorp"); + assert_eq!(info.model, "Virtual Phone"); + assert_eq!(info.serial_number, "test-001"); + assert!(device.supports_rename()); + } + + #[tokio::test] + async fn list_objects_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let objects = storages[0].list_objects(None).await.unwrap(); + + assert!(objects.is_empty()); + } + + #[tokio::test] + async fn list_objects_with_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); + std::fs::write(dir.path().join("photo.jpg"), "fake jpeg data").unwrap(); + std::fs::create_dir(dir.path().join("Documents")).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let items = storages[0].list_objects(None).await.unwrap(); + + assert_eq!(items.len(), 3); + let names: Vec<&str> = items.iter().map(|i| i.filename.as_str()).collect(); + assert!(names.contains(&"hello.txt")); + assert!(names.contains(&"photo.jpg")); + assert!(names.contains(&"Documents")); + + // Verify folder detection + let docs = items.iter().find(|i| i.filename == "Documents").unwrap(); + assert!(docs.is_folder()); + assert_eq!(docs.format, ObjectFormatCode::Association); + + // Verify file metadata + let txt = items.iter().find(|i| i.filename == "hello.txt").unwrap(); + assert!(txt.is_file()); + assert_eq!(txt.size, 11); // "hello world" = 11 bytes + } + + #[tokio::test] + async fn download_file() { + let dir = tempfile::tempdir().unwrap(); + let content = b"test file content for download"; + std::fs::write(dir.path().join("test.txt"), content).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + let data = storages[0].download(obj.handle).await.unwrap(); + assert_eq!(data.as_slice(), content); + } + + #[tokio::test] + async fn download_partial_reads_byte_ranges() { + let dir = tempfile::tempdir().unwrap(); + let content: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + std::fs::write(dir.path().join("data.bin"), &content).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + // Read from the beginning. + let head = storages[0] + .download_partial(obj.handle, 0, 100) + .await + .unwrap(); + assert_eq!(head, &content[0..100]); + + // Read from the middle. + let middle = storages[0] + .download_partial(obj.handle, 500, 100) + .await + .unwrap(); + assert_eq!(middle, &content[500..600]); + + // Read past the end: device returns whatever's left. + let tail = storages[0] + .download_partial(obj.handle, 990, 1000) + .await + .unwrap(); + assert_eq!(tail, &content[990..1000]); + } + + #[tokio::test] + async fn download_partial_64_reads_byte_ranges() { + let dir = tempfile::tempdir().unwrap(); + let content: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + std::fs::write(dir.path().join("data.bin"), &content).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + // Same scenarios as the 32-bit version, using the 64-bit op. + let head = storages[0] + .download_partial_64(obj.handle, 0, 100) + .await + .unwrap(); + assert_eq!(head, &content[0..100]); + + let middle = storages[0] + .download_partial_64(obj.handle, 500, 100) + .await + .unwrap(); + assert_eq!(middle, &content[500..600]); + + let tail = storages[0] + .download_partial_64(obj.handle, 990, 1000) + .await + .unwrap(); + assert_eq!(tail, &content[990..1000]); + } + + #[tokio::test] + async fn download_partial_64_reassembles_offset_correctly() { + // Verifies the lo/hi u32 → u64 round-trip. We can't realistically create a >4GB + // file, so instead we test that an offset whose low u32 is 0 (i.e. an exact + // multiple of 2^32) routes through the same code path with no truncation. + // For a small file, any offset >= file length returns an empty read, which + // still proves the u64 offset made it through correctly (if the high bits + // were dropped, we'd incorrectly read from the start of the file). + let dir = tempfile::tempdir().unwrap(); + let content: Vec = (0..100).map(|i| i as u8).collect(); + std::fs::write(dir.path().join("small.bin"), &content).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + // Offset = 2^32 + 10. If the hi u32 were dropped, this would read from byte 10. + let offset_beyond_4gb: u64 = (1u64 << 32) + 10; + let data = storages[0] + .download_partial_64(obj.handle, offset_beyond_4gb, 50) + .await + .unwrap(); + assert!( + data.is_empty(), + "offset past EOF should return empty, got {} bytes — high offset bits may be getting dropped", + data.len() + ); + } + + #[tokio::test] + async fn upload_file() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let info = crate::mtp::NewObjectInfo::file("uploaded.txt", 12); + let handle = storages[0] + .upload(None, info, bytes_stream(b"hello upload")) + .await + .unwrap(); + + // Verify file exists on disk + let path = dir.path().join("uploaded.txt"); + assert!(path.exists()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello upload"); + + // Verify we can download it back + let data = storages[0].download(handle).await.unwrap(); + assert_eq!(data.as_slice(), b"hello upload"); + } + + #[tokio::test] + async fn upload_to_subfolder() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("Music")).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // List root to get the Music folder handle + let items = storages[0].list_objects(None).await.unwrap(); + let music = items.iter().find(|i| i.filename == "Music").unwrap(); + assert!(music.is_folder()); + + // Upload a file into Music + let info = crate::mtp::NewObjectInfo::file("song.mp3", 5); + storages[0] + .upload(Some(music.handle), info, bytes_stream(b"audio")) + .await + .unwrap(); + + assert!(dir.path().join("Music/song.mp3").exists()); + assert_eq!( + std::fs::read_to_string(dir.path().join("Music/song.mp3")).unwrap(), + "audio" + ); + } + + #[tokio::test] + async fn delete_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("doomed.txt"), "goodbye").unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + storages[0].delete(obj.handle).await.unwrap(); + assert!(!dir.path().join("doomed.txt").exists()); + } + + #[tokio::test] + async fn create_folder() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + storages[0].create_folder(None, "NewFolder").await.unwrap(); + + assert!(dir.path().join("NewFolder").is_dir()); + } + + #[tokio::test] + async fn rename_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("old_name.txt"), "content").unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let items = storages[0].list_objects(None).await.unwrap(); + let obj = &items[0]; + + storages[0] + .rename(obj.handle, "new_name.txt") + .await + .unwrap(); + + assert!(!dir.path().join("old_name.txt").exists()); + assert!(dir.path().join("new_name.txt").exists()); + assert_eq!( + std::fs::read_to_string(dir.path().join("new_name.txt")).unwrap(), + "content" + ); + } + + #[tokio::test] + async fn nested_directories() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap(); + std::fs::write(dir.path().join("a/b/c/deep.txt"), "deep").unwrap(); + std::fs::write(dir.path().join("a/top.txt"), "top").unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // List root + let root_items = storages[0].list_objects(None).await.unwrap(); + assert_eq!(root_items.len(), 1); // Only "a" + assert_eq!(root_items[0].filename, "a"); + assert!(root_items[0].is_folder()); + } + + #[tokio::test] + async fn read_only_storage_rejects_writes() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("existing.txt"), "data").unwrap(); + + let config = test_config_readonly(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // Verify read-only access capability is reported + assert_ne!( + storages[0].info().access_capability, + crate::ptp::AccessCapability::ReadWrite + ); + + // Upload should fail + let info = crate::mtp::NewObjectInfo::file("new.txt", 4); + let result = storages[0].upload(None, info, bytes_stream(b"data")).await; + assert!(result.is_err()); + + // Delete should fail + let items = storages[0].list_objects(None).await.unwrap(); + let result = storages[0].delete(items[0].handle).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn multiple_storages() { + let dir1 = tempfile::tempdir().unwrap(); + let dir2 = tempfile::tempdir().unwrap(); + std::fs::write(dir1.path().join("file1.txt"), "storage1").unwrap(); + std::fs::write(dir2.path().join("file2.txt"), "storage2").unwrap(); + + let config = test_config_multi(&[dir1.path(), dir2.path()]); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + assert_eq!(storages.len(), 2); + + let items1 = storages[0].list_objects(None).await.unwrap(); + assert_eq!(items1.len(), 1); + assert_eq!(items1[0].filename, "file1.txt"); + + let items2 = storages[1].list_objects(None).await.unwrap(); + assert_eq!(items2.len(), 1); + assert_eq!(items2[0].filename, "file2.txt"); + } + + #[tokio::test] + async fn free_space_reflects_content() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let free_before = storages[0].info().free_space_bytes; + + // Upload a file + let info = crate::mtp::NewObjectInfo::file("big.bin", 1000); + let data = vec![0u8; 1000]; + storages[0] + .upload(None, info, bytes_stream(&data)) + .await + .unwrap(); + + // Re-fetch storage info + let storages2 = device.storages().await.unwrap(); + let free_after = storages2[0].info().free_space_bytes; + + assert!(free_after < free_before); + assert_eq!(free_before - free_after, 1000); + } + + #[tokio::test] + async fn events_generated_on_upload() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let info = crate::mtp::NewObjectInfo::file("event_test.txt", 5); + storages[0] + .upload(None, info, bytes_stream(b"hello")) + .await + .unwrap(); + + // Events should be available (ObjectAdded + StorageInfoChanged) + use tokio::time::{timeout, Duration}; + let event = timeout(Duration::from_millis(100), device.next_event()).await; + assert!(event.is_ok()); + } + + #[tokio::test] + async fn events_generated_on_delete() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("to_delete.txt"), "bye").unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let items = storages[0].list_objects(None).await.unwrap(); + storages[0].delete(items[0].handle).await.unwrap(); + + // Should have ObjectRemoved + StorageInfoChanged events + use tokio::time::{timeout, Duration}; + let event = timeout(Duration::from_millis(100), device.next_event()).await; + assert!(event.is_ok()); + } + + #[tokio::test] + async fn no_events_returns_timeout() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + + // No operations performed, so no events + let result = device.next_event().await; + assert!(matches!(result, Err(crate::Error::Timeout))); + } + + #[tokio::test] + async fn copy_object() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("original.txt"), "copy me").unwrap(); + std::fs::create_dir(dir.path().join("dest")).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let items = storages[0].list_objects(None).await.unwrap(); + let original = items.iter().find(|i| i.filename == "original.txt").unwrap(); + let dest = items.iter().find(|i| i.filename == "dest").unwrap(); + + storages[0] + .copy_object(original.handle, dest.handle, None) + .await + .unwrap(); + + // Both should exist + assert!(dir.path().join("original.txt").exists()); + assert!(dir.path().join("dest/original.txt").exists()); + assert_eq!( + std::fs::read_to_string(dir.path().join("dest/original.txt")).unwrap(), + "copy me" + ); + } + + #[tokio::test] + async fn path_traversal_rejected() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // Try to upload a file with ".." in the name + let info = crate::mtp::NewObjectInfo::file("../escape.txt", 6); + let result = storages[0] + .upload(None, info, bytes_stream(b"escape")) + .await; + assert!(result.is_err(), "path traversal upload should be rejected"); + + // Verify the file was NOT created outside the backing dir + assert!(!dir.path().parent().unwrap().join("escape.txt").exists()); + } + + #[tokio::test] + async fn move_object() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("moveme.txt"), "move me").unwrap(); + std::fs::create_dir(dir.path().join("target")).unwrap(); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + let items = storages[0].list_objects(None).await.unwrap(); + let moveme = items.iter().find(|i| i.filename == "moveme.txt").unwrap(); + let target = items.iter().find(|i| i.filename == "target").unwrap(); + + storages[0] + .move_object(moveme.handle, target.handle, None) + .await + .unwrap(); + + assert!(!dir.path().join("moveme.txt").exists()); + assert!(dir.path().join("target/moveme.txt").exists()); + assert_eq!( + std::fs::read_to_string(dir.path().join("target/moveme.txt")).unwrap(), + "move me" + ); + + // Should emit ObjectInfoChanged + StorageInfoChanged events + use tokio::time::{timeout, Duration}; + let event = timeout(Duration::from_millis(100), device.next_event()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + event, + crate::mtp::DeviceEvent::ObjectInfoChanged { .. } + )); + let event = timeout(Duration::from_millis(100), device.next_event()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + event, + crate::mtp::DeviceEvent::StorageInfoChanged { .. } + )); + } + + /// Helper: poll for an event, retrying on Timeout up to the deadline. + async fn poll_event_with_retry( + device: &MtpDevice, + timeout_duration: std::time::Duration, + ) -> Option { + let deadline = tokio::time::Instant::now() + timeout_duration; + loop { + match device.next_event().await { + Ok(event) => return Some(event), + Err(crate::Error::Timeout) => { + if tokio::time::Instant::now() >= deadline { + return None; + } + } + Err(_) => return None, + } + } + } + + #[tokio::test] + async fn fs_watcher_detects_file_creation() { + let dir = tempfile::tempdir().unwrap(); + // Canonicalize the backing dir to avoid macOS /var vs /private/var mismatches + let backing_dir = dir.path().canonicalize().unwrap(); + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-fswatch".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: backing_dir.clone(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::from_millis(50), + watch_backing_dirs: true, + }; + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + + // Write a file directly to the backing dir (bypassing MTP) + std::fs::write(backing_dir.join("external.txt"), "hello from outside").unwrap(); + + // Poll for events — the watcher should detect the file creation. + let event = poll_event_with_retry(&device, Duration::from_secs(5)).await; + assert!( + event.is_some(), + "expected event from fs watcher, got nothing" + ); + let event = event.unwrap(); + assert!( + matches!(event, crate::mtp::DeviceEvent::ObjectAdded { .. }), + "expected ObjectAdded, got {:?}", + event + ); + } + + /// Helper: create a virtual device with a pre-existing subdirectory and + /// return (device, backing_dir, tempdir-guard). + async fn virtual_device_with_subdirectory( + serial: &str, + ) -> (MtpDevice, std::path::PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let backing_dir = dir.path().canonicalize().unwrap(); + std::fs::create_dir(backing_dir.join("Music")).unwrap(); + + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: serial.into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: backing_dir.clone(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::from_millis(50), + watch_backing_dirs: true, + }; + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + + // Drain any startup events (macOS FSEvents may report the watched dir). + while poll_event_with_retry(&device, Duration::from_millis(500)) + .await + .is_some() + {} + + (device, backing_dir, dir) + } + + #[tokio::test] + async fn fs_watcher_detects_file_creation_in_subdirectory() { + let (device, backing_dir, _dir) = + virtual_device_with_subdirectory("test-fswatch-subdir-create").await; + + std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); + + let event = poll_event_with_retry(&device, Duration::from_secs(5)).await; + assert!( + event.is_some(), + "expected event from fs watcher for file in subdirectory, got nothing" + ); + assert!( + matches!(event.unwrap(), crate::mtp::DeviceEvent::ObjectAdded { .. }), + "expected ObjectAdded" + ); + } + + #[tokio::test] + async fn fs_watcher_detects_file_rename_in_subdirectory() { + let (device, backing_dir, _dir) = + virtual_device_with_subdirectory("test-fswatch-subdir-rename").await; + + // Create a file and drain its events. + std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); + while poll_event_with_retry(&device, Duration::from_secs(5)) + .await + .is_some() + {} + + // Rename the file within the subdirectory. + std::fs::rename( + backing_dir.join("Music/song.mp3"), + backing_dir.join("Music/track.mp3"), + ) + .unwrap(); + + // A rename should produce an ObjectRemoved (old name) and ObjectAdded (new name). + let mut events = Vec::new(); + while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { + events.push(event); + let has_removed = events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })); + let has_added = events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })); + if has_removed && has_added { + break; + } + } + assert!( + events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), + "expected ObjectRemoved for rename source, got {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })), + "expected ObjectAdded for rename target, got {:?}", + events + ); + } + + #[tokio::test] + async fn fs_watcher_detects_file_removal_in_subdirectory() { + let (device, backing_dir, _dir) = + virtual_device_with_subdirectory("test-fswatch-subdir-remove").await; + + // Create a file and drain its events. + std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); + while poll_event_with_retry(&device, Duration::from_secs(5)) + .await + .is_some() + {} + + // Delete the file. + std::fs::remove_file(backing_dir.join("Music/song.mp3")).unwrap(); + + let mut events = Vec::new(); + while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { + events.push(event); + if events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })) + { + break; + } + } + assert!( + events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), + "expected ObjectRemoved for file in subdirectory, got {:?}", + events + ); + } + + #[tokio::test] + async fn fs_watcher_detects_file_removal() { + let dir = tempfile::tempdir().unwrap(); + let backing_dir = dir.path().canonicalize().unwrap(); + + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-fswatch-rm".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: backing_dir.clone(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::from_millis(50), + watch_backing_dirs: true, + }; + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + + // Create the file AFTER the watcher is running, so we get a clean event sequence + std::fs::write(backing_dir.join("will_be_removed.txt"), "bye").unwrap(); + + // Drain events until no more arrive (consume the ObjectAdded from creation) + while poll_event_with_retry(&device, Duration::from_millis(500)) + .await + .is_some() + {} + + // Now remove the file directly (bypassing MTP) + std::fs::remove_file(backing_dir.join("will_be_removed.txt")).unwrap(); + + // Collect all events and look for ObjectRemoved + let mut events = Vec::new(); + while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { + events.push(event); + // Stop after we find what we need or have collected enough + if events.len() >= 10 { + break; + } + if events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })) + { + break; + } + } + + assert!( + events + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), + "expected ObjectRemoved among events, got {:?}", + events + ); + } + + #[tokio::test] + async fn fs_watcher_dedup_suppresses_mtp_events() { + let dir = tempfile::tempdir().unwrap(); + let backing_dir = dir.path().canonicalize().unwrap(); + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: "test-fswatch-dedup".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: backing_dir.clone(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::from_millis(50), + watch_backing_dirs: true, + }; + + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // Upload via MTP — should produce exactly the MTP-generated events + let info = crate::mtp::NewObjectInfo::file("dedup_test.txt", 5); + storages[0] + .upload(None, info, bytes_stream(b"hello")) + .await + .unwrap(); + + // Drain all events with a generous window for the watcher to fire. + // MTP upload produces 1 ObjectAdded + 1 StorageInfoChanged. + // The watcher sees the file creation but finds the handle already exists + // in state.objects (inserted by the MTP handler under the mutex), so it + // skips the event — no duplicate ObjectAdded. + // We count ObjectAdded specifically because some platforms (Linux inotify) + // may generate additional filesystem events (StorageInfoChanged etc.). + let mut object_added_count = 0; + let mut total_events = 0; + while let Some(event) = poll_event_with_retry(&device, Duration::from_millis(500)).await { + if matches!(event, crate::mtp::DeviceEvent::ObjectAdded { .. }) { + object_added_count += 1; + } + total_events += 1; + if total_events > 10 { + break; + } + } + + // Exactly 1 ObjectAdded from the MTP handler. The watcher's dedup + // suppresses duplicates. + assert_eq!( + object_added_count, 1, + "expected exactly 1 ObjectAdded event, got {} (dedup may have failed)", + object_added_count + ); + } + + fn test_config_with_serial(dir: &std::path::Path, serial: &str) -> VirtualDeviceConfig { + VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: serial.into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: dir.to_path_buf(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + } + } + + #[tokio::test] + async fn rescan_detects_external_changes() { + let dir = tempfile::tempdir().unwrap(); + // Create initial files before opening the device. + std::fs::write(dir.path().join("existing.txt"), "hello").unwrap(); + std::fs::create_dir(dir.path().join("Photos")).unwrap(); + std::fs::write(dir.path().join("Photos/pic.jpg"), "jpeg data").unwrap(); + + let config = test_config_with_serial(dir.path(), "rescan-test-001"); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // List all objects to populate the in-memory tree. + let root_items = storages[0].list_objects(None).await.unwrap(); + assert_eq!(root_items.len(), 2); // existing.txt + Photos + let photos = root_items.iter().find(|i| i.filename == "Photos").unwrap(); + let _sub_items = storages[0].list_objects(Some(photos.handle)).await.unwrap(); + + // Drain any events from listing. + while device.next_event().await.is_ok() {} + + // --- Externally modify the backing dir (bypassing MTP) --- + // Delete existing.txt + std::fs::remove_file(dir.path().join("existing.txt")).unwrap(); + // Create a new file + std::fs::write(dir.path().join("new_file.txt"), "I'm new").unwrap(); + // Delete the file inside Photos + std::fs::remove_file(dir.path().join("Photos/pic.jpg")).unwrap(); + + // Rescan via the public API. + let summary = crate::rescan_virtual_device("rescan-test-001").unwrap(); + assert_eq!( + summary.removed, 2, + "should remove existing.txt and Photos/pic.jpg" + ); + assert_eq!(summary.added, 1, "should add new_file.txt"); + + // Verify the object tree now matches the filesystem. + let root_items = storages[0].list_objects(None).await.unwrap(); + let names: Vec<&str> = root_items.iter().map(|i| i.filename.as_str()).collect(); + assert!( + names.contains(&"new_file.txt"), + "new_file.txt should appear: {:?}", + names + ); + assert!( + names.contains(&"Photos"), + "Photos dir still exists: {:?}", + names + ); + assert!( + !names.contains(&"existing.txt"), + "existing.txt should be gone: {:?}", + names + ); + + // Verify Photos subdirectory is now empty. + let photos = root_items.iter().find(|i| i.filename == "Photos").unwrap(); + let sub_items = storages[0].list_objects(Some(photos.handle)).await.unwrap(); + assert!( + sub_items.is_empty(), + "Photos should be empty after pic.jpg was removed" + ); + + // Verify events were queued (ObjectRemoved, ObjectAdded, StorageInfoChanged). + let mut event_types = Vec::new(); + loop { + match device.next_event().await { + Ok(e) => event_types.push(e), + Err(crate::Error::Timeout) => break, + Err(_) => break, + } + } + assert!( + event_types + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })), + "expected ObjectAdded event from rescan" + ); + assert!( + event_types + .iter() + .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), + "expected ObjectRemoved event from rescan" + ); + } + + #[test] + fn rescan_nonexistent_serial_returns_none() { + assert!( + crate::transport::virtual_device::registry::rescan_virtual_device("no-such-device") + .is_none() + ); + } + + #[tokio::test] + async fn rescan_no_changes_returns_zero() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("stable.txt"), "unchanged").unwrap(); + + let config = test_config_with_serial(dir.path(), "rescan-test-002"); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + + // Populate the object tree. + let _ = storages[0].list_objects(None).await.unwrap(); + + let summary = crate::rescan_virtual_device("rescan-test-002").unwrap(); + assert_eq!(summary.added, 0); + assert_eq!(summary.removed, 0); + + drop(device); + } + + #[tokio::test] + async fn list_objects_resolves_full_size_for_files_larger_than_4gb() { + // Create a 5 GB sparse file on the backing filesystem. Its ObjectInfo size + // field will saturate at u32::MAX; the real u64 size must be resolved via + // GetObjectPropValue(ObjectSize). + const REAL_SIZE: u64 = 5 * 1024 * 1024 * 1024; + + let dir = tempfile::tempdir().unwrap(); + let big_path = dir.path().join("movie.mkv"); + let file = std::fs::File::create(&big_path).unwrap(); + file.set_len(REAL_SIZE).unwrap(); + drop(file); + + let config = test_config(dir.path()); + let device = MtpDevice::builder().open_virtual(config).await.unwrap(); + let storages = device.storages().await.unwrap(); + let objects = storages[0].list_objects(None).await.unwrap(); + + let movie = objects + .iter() + .find(|o| o.filename == "movie.mkv") + .expect("movie.mkv not found in listing"); + assert_eq!( + movie.size, REAL_SIZE, + "full u64 size should be resolved for files >4 GB" + ); + } +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/registry.rs b/vendor/mtp-rs/src/transport/virtual_device/registry.rs new file mode 100644 index 0000000..1215209 --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/registry.rs @@ -0,0 +1,378 @@ +//! Global registry of virtual devices for discovery integration. +//! +//! Virtual devices registered here appear in `MtpDevice::list_devices()` and +//! can be opened via `open_by_location()` or `open_by_serial()`. + +use super::config::VirtualDeviceConfig; +use super::state::{RescanSummary, VirtualDeviceState}; +use crate::mtp::MtpDeviceInfo; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Base for synthetic location IDs (high range, won't collide with real USB). +const VIRTUAL_LOCATION_BASE: u64 = 0xFFFF_0000_0000_0000; + +/// A registered virtual device. +struct VirtualRegistration { + info: MtpDeviceInfo, + config: VirtualDeviceConfig, +} + +/// Holds registered devices and a monotonically increasing index for unique location IDs. +struct Registry { + devices: Vec, + next_index: u64, +} + +/// Access the global registry singleton. +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + Mutex::new(Registry { + devices: Vec::new(), + next_index: 0, + }) + }) +} + +/// Register a virtual device so it appears in `MtpDevice::list_devices()`. +/// +/// Returns the `MtpDeviceInfo` with a synthetic location ID. Use this location +/// ID with `MtpDevice::open_by_location()` or the serial with +/// `MtpDevice::open_by_serial()` to open the device. +#[must_use] +pub fn register_virtual_device(config: &VirtualDeviceConfig) -> MtpDeviceInfo { + let mut reg = registry().lock().unwrap(); + let index = reg.next_index; + reg.next_index += 1; + let location_id = VIRTUAL_LOCATION_BASE + index; + + let info = MtpDeviceInfo { + vendor_id: 0xFFFF, + product_id: 0x0001, + manufacturer: Some(config.manufacturer.clone()), + product: Some(config.model.clone()), + serial_number: Some(config.serial.clone()), + location_id, + }; + + reg.devices.push(VirtualRegistration { + info: info.clone(), + config: config.clone(), + }); + + info +} + +/// Remove a registered virtual device by location ID. +pub fn unregister_virtual_device(location_id: u64) { + let mut reg = registry().lock().unwrap(); + reg.devices.retain(|r| r.info.location_id != location_id); +} + +/// Get all registered virtual devices (called by `list_devices`). +pub(crate) fn list_virtual_devices() -> Vec { + let reg = registry().lock().unwrap(); + reg.devices.iter().map(|r| r.info.clone()).collect() +} + +/// Try to find a virtual device config by location ID. +pub(crate) fn find_virtual_config_by_location(location_id: u64) -> Option { + let reg = registry().lock().unwrap(); + reg.devices + .iter() + .find(|r| r.info.location_id == location_id) + .map(|r| r.config.clone()) +} + +/// Try to find a virtual device config by serial number. +pub(crate) fn find_virtual_config_by_serial(serial: &str) -> Option { + let reg = registry().lock().unwrap(); + reg.devices + .iter() + .find(|r| r.info.serial_number.as_deref() == Some(serial)) + .map(|r| r.config.clone()) +} + +// --- Active device state registry --- +// +// When a VirtualTransport is created, it registers its shared state here so +// that `rescan_virtual_device()` can look it up by serial number. Entries are +// removed when the transport is dropped. + +/// An entry in the active-states registry: (serial, shared state). +type ActiveEntry = (String, Arc>); + +/// Access the global active-states registry. +fn active_states() -> &'static Mutex> { + static ACTIVE: OnceLock>> = OnceLock::new(); + ACTIVE.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Register an active virtual device's state (called by `VirtualTransport::new`). +pub(super) fn register_active_state(serial: String, state: Arc>) { + let mut active = active_states().lock().unwrap(); + active.push((serial, state)); +} + +/// Unregister an active virtual device's state (called when `VirtualTransport` is dropped). +pub(super) fn unregister_active_state(serial: &str) { + let mut active = active_states().lock().unwrap(); + if let Some(pos) = active.iter().position(|(s, _)| s == serial) { + active.remove(pos); + } +} + +/// RAII guard that resumes the filesystem watcher when dropped. +/// +/// Created by [`pause_watcher`]. While this guard is alive, all filesystem +/// events for the device are silently dropped. +pub struct WatcherGuard { + state: Arc>, +} + +impl Drop for WatcherGuard { + fn drop(&mut self) { + if let Ok(mut state) = self.state.lock() { + state.watcher_paused = false; + } + } +} + +/// Pause the filesystem watcher for a virtual device, returning a guard that +/// resumes it on drop. +/// +/// While paused, filesystem events are silently dropped. This prevents stale +/// OS-level events from corrupting the object tree when files in the backing +/// directory are deleted and recreated externally (outside of MTP). Without +/// pausing, delayed deletion events can arrive after a rescan has already +/// re-added the objects, incorrectly removing them. +/// +/// Returns `None` if no active virtual device with that serial exists. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::{pause_watcher, rescan_virtual_device}; +/// +/// { +/// let _guard = pause_watcher("my-device-serial").unwrap(); +/// +/// // ... delete and recreate files in the backing directory ... +/// +/// rescan_virtual_device("my-device-serial"); +/// } // watcher resumes here when `_guard` is dropped +/// ``` +pub fn pause_watcher(serial: &str) -> Option { + let active = active_states().lock().unwrap(); + let state_arc = active + .iter() + .find(|(s, _)| s == serial) + .map(|(_, state)| Arc::clone(state))?; + drop(active); + state_arc.lock().unwrap().watcher_paused = true; + Some(WatcherGuard { state: state_arc }) +} + +/// Force a rescan of a virtual device's backing directories, identified by +/// serial number. +/// +/// This diffs the in-memory object tree against the actual filesystem and +/// queues `ObjectAdded`/`ObjectRemoved`/`StorageInfoChanged` events for any +/// differences found. +/// +/// Returns `Some(summary)` with the number of added/removed objects, or +/// `None` if no active virtual device with that serial exists. +/// +/// # When to use +/// +/// Call this after manipulating files in the backing directory directly on +/// disk (outside of MTP) when you need the virtual device to reflect those +/// changes immediately. This avoids waiting for the filesystem watcher's +/// latency (200–500ms on macOS). For delete+recreate sequences, pair with +/// [`pause_watcher`] to prevent stale events from corrupting state. +/// +/// # Example +/// +/// ```rust,no_run +/// use mtp_rs::rescan_virtual_device; +/// +/// // After manipulating files in the backing directory... +/// if let Some(summary) = rescan_virtual_device("my-device-serial") { +/// println!("Rescan: {} added, {} removed", summary.added, summary.removed); +/// } +/// ``` +pub fn rescan_virtual_device(serial: &str) -> Option { + let active = active_states().lock().unwrap(); + let state_arc = active + .iter() + .find(|(s, _)| s == serial) + .map(|(_, state)| Arc::clone(state))?; + drop(active); // Release the registry lock before acquiring the state lock. + let mut state = state_arc.lock().unwrap(); + Some(state.rescan_backing_dirs()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::virtual_device::config::VirtualStorageConfig; + use std::time::Duration; + + fn make_config(serial: &str) -> (VirtualDeviceConfig, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Virtual Phone".into(), + serial: serial.into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: dir.path().to_path_buf(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + }; + (config, dir) + } + + #[test] + fn register_and_list() { + let (config, _dir) = make_config("reg-test-001"); + let info = register_virtual_device(&config); + + assert!(info.location_id >= VIRTUAL_LOCATION_BASE); + assert_eq!(info.serial_number.as_deref(), Some("reg-test-001")); + assert_eq!(info.manufacturer.as_deref(), Some("TestCorp")); + + let devices = list_virtual_devices(); + assert!(devices + .iter() + .any(|d| d.serial_number.as_deref() == Some("reg-test-001"))); + + // Clean up + unregister_virtual_device(info.location_id); + } + + #[test] + fn find_by_location() { + let (config, _dir) = make_config("reg-test-002"); + let info = register_virtual_device(&config); + + let found = find_virtual_config_by_location(info.location_id); + assert!(found.is_some()); + assert_eq!(found.unwrap().serial, "reg-test-002"); + + // Clean up + unregister_virtual_device(info.location_id); + } + + #[test] + fn find_by_serial() { + let (config, _dir) = make_config("reg-test-003"); + let info = register_virtual_device(&config); + + let found = find_virtual_config_by_serial("reg-test-003"); + assert!(found.is_some()); + assert_eq!(found.unwrap().serial, "reg-test-003"); + + // Not found + assert!(find_virtual_config_by_serial("nonexistent").is_none()); + + // Clean up + unregister_virtual_device(info.location_id); + } + + #[test] + fn unregister() { + let (config, _dir) = make_config("reg-test-004"); + let info = register_virtual_device(&config); + + unregister_virtual_device(info.location_id); + + assert!(find_virtual_config_by_location(info.location_id).is_none()); + assert!(find_virtual_config_by_serial("reg-test-004").is_none()); + } + + #[test] + fn location_id_unique_after_unregister() { + let (config_a, _dir_a) = make_config("reg-test-unique-a"); + let info_a = register_virtual_device(&config_a); + + let (config_b, _dir_b) = make_config("reg-test-unique-b"); + let info_b = register_virtual_device(&config_b); + + // Unregister A + unregister_virtual_device(info_a.location_id); + + // Register C — must get a unique location_id different from both A and B + let (config_c, _dir_c) = make_config("reg-test-unique-c"); + let info_c = register_virtual_device(&config_c); + + assert_ne!(info_c.location_id, info_a.location_id); + assert_ne!(info_c.location_id, info_b.location_id); + + // Clean up + unregister_virtual_device(info_b.location_id); + unregister_virtual_device(info_c.location_id); + } + + #[tokio::test] + async fn open_by_location_integration() { + let dir = tempfile::tempdir().unwrap(); + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Registry Phone".into(), + serial: "reg-test-005".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: dir.path().to_path_buf(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + }; + let info = register_virtual_device(&config); + + let device = crate::MtpDevice::builder() + .open_by_location(info.location_id) + .await + .unwrap(); + assert_eq!(device.device_info().model, "Registry Phone"); + + // Clean up + unregister_virtual_device(info.location_id); + } + + #[tokio::test] + async fn open_by_serial_integration() { + let dir = tempfile::tempdir().unwrap(); + let config = VirtualDeviceConfig { + manufacturer: "TestCorp".into(), + model: "Registry Phone".into(), + serial: "reg-test-006".into(), + storages: vec![VirtualStorageConfig { + description: "Internal Storage".into(), + capacity: 1024 * 1024 * 1024, + backing_dir: dir.path().to_path_buf(), + read_only: false, + }], + supports_rename: true, + event_poll_interval: Duration::ZERO, + watch_backing_dirs: false, + }; + let info = register_virtual_device(&config); + + let device = crate::MtpDevice::builder() + .open_by_serial("reg-test-006") + .await + .unwrap(); + assert_eq!(device.device_info().model, "Registry Phone"); + + // Clean up + unregister_virtual_device(info.location_id); + } +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/state.rs b/vendor/mtp-rs/src/transport/virtual_device/state.rs new file mode 100644 index 0000000..38f3190 --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/state.rs @@ -0,0 +1,367 @@ +//! Internal state for the virtual MTP device. + +use super::builders::build_event; +use super::config::{VirtualDeviceConfig, VirtualStorageConfig}; +use crate::ptp::{EventCode, ObjectHandle, StorageId}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; + +/// Per-storage state. +#[derive(Debug)] +pub(super) struct VirtualStorageState { + pub config: VirtualStorageConfig, + pub storage_id: StorageId, +} + +/// A tracked virtual object (file or directory). +#[derive(Debug, Clone)] +pub(super) struct VirtualObject { + /// Path relative to the storage backing dir. + pub rel_path: PathBuf, + /// Which storage this object belongs to. + pub storage_id: StorageId, + /// Parent handle (`ObjectHandle::ROOT` for root-level objects). + pub parent: ObjectHandle, +} + +/// Pending SendObjectInfo state (MTP requires SendObjectInfo before SendObject). +#[derive(Debug, Clone)] +pub(super) struct PendingSendInfo { + pub storage_id: StorageId, + pub parent: ObjectHandle, + pub filename: String, + #[allow(dead_code)] // Part of the protocol state, kept for debugging + pub size: u64, + pub is_folder: bool, + pub assigned_handle: ObjectHandle, +} + +/// A command waiting for its data phase from the host. +/// +/// When the host sends a command that expects a data phase (SendObjectInfo, +/// SendObject, SetObjectPropValue), the command is stored here. The next +/// `send_bulk` (data container) takes it and dispatches both together. +#[derive(Debug)] +pub(super) struct PendingCommand { + pub code: u16, + pub tx_id: u32, + pub params: Vec, +} + +/// Full mutable state of the virtual device. +#[derive(Debug)] +pub(super) struct VirtualDeviceState { + pub config: VirtualDeviceConfig, + pub session_open: bool, + pub next_handle: u32, + pub objects: HashMap, + pub storages: Vec, + pub pending_send: Option, + pub pending_command: Option, + pub event_queue: VecDeque>, + pub response_queue: VecDeque>, + /// When true, the filesystem watcher silently drops all events. Managed + /// by [`WatcherGuard`](super::registry::WatcherGuard) via + /// [`pause_watcher`](super::registry::pause_watcher). + pub(super) watcher_paused: bool, +} + +impl VirtualDeviceState { + /// Create initial state from config. + pub fn new(config: VirtualDeviceConfig) -> Self { + let storages: Vec = config + .storages + .iter() + .enumerate() + .map(|(i, sc)| { + // Canonicalize backing dirs so that all paths (from handlers, watcher, + // dedup tracker) use the same form. On macOS, /var → /private/var. + let mut resolved_config = sc.clone(); + if let Ok(canonical) = sc.backing_dir.canonicalize() { + resolved_config.backing_dir = canonical; + } + VirtualStorageState { + config: resolved_config, + // Storage IDs conventionally start at 0x00010001 + storage_id: StorageId(0x0001_0001 + i as u32), + } + }) + .collect(); + + Self { + config, + session_open: false, + next_handle: 1, + objects: HashMap::new(), + storages, + pending_send: None, + pending_command: None, + event_queue: VecDeque::new(), + response_queue: VecDeque::new(), + watcher_paused: false, + } + } + + /// Allocate the next object handle. + pub fn alloc_handle(&mut self) -> ObjectHandle { + let h = self.next_handle; + self.next_handle += 1; + ObjectHandle(h) + } + + /// Find a storage state by ID. + pub fn find_storage(&self, id: StorageId) -> Option<&VirtualStorageState> { + self.storages.iter().find(|s| s.storage_id == id) + } + + /// Find or create handles for all entries in a directory. + /// Returns handles for direct children of `parent` within the given storage. + pub fn scan_dir( + &mut self, + storage_id: StorageId, + parent: ObjectHandle, + ) -> Result, std::io::Error> { + let storage = self + .storages + .iter() + .find(|s| s.storage_id == storage_id) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "storage not found") + })?; + + let base_dir = &storage.config.backing_dir; + + // Determine the filesystem path for this parent + let dir_path = if parent == ObjectHandle::ROOT || parent.0 == 0 { + base_dir.clone() + } else { + match self.objects.get(&parent.0) { + Some(obj) if obj.storage_id == storage_id => base_dir.join(&obj.rel_path), + _ => return Ok(Vec::new()), + } + }; + + if !dir_path.is_dir() { + return Ok(Vec::new()); + } + + let mut handles = Vec::new(); + let entries = std::fs::read_dir(&dir_path)?; + + for entry in entries { + let entry = entry?; + let file_name = entry.file_name(); + let rel = if parent == ObjectHandle::ROOT || parent.0 == 0 { + PathBuf::from(&file_name) + } else { + let parent_obj = self.objects.get(&parent.0).unwrap(); + parent_obj.rel_path.join(&file_name) + }; + + // Check if we already have a handle for this path in this storage + let existing = self + .objects + .iter() + .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel); + + let handle = if let Some((&h, _)) = existing { + ObjectHandle(h) + } else { + let h = self.alloc_handle(); + self.objects.insert( + h.0, + VirtualObject { + rel_path: rel, + storage_id, + parent, + }, + ); + h + }; + + handles.push(handle); + } + + Ok(handles) + } + + /// Recursively scan all objects in a storage. + pub fn scan_all(&mut self, storage_id: StorageId) -> Result, std::io::Error> { + // First scan root + let root_handles = self.scan_dir(storage_id, ObjectHandle::ROOT)?; + let mut all_handles = root_handles.clone(); + + // BFS through directories + let mut queue = root_handles; + while let Some(handle) = queue.pop() { + let obj = match self.objects.get(&handle.0) { + Some(o) => o.clone(), + None => continue, + }; + let storage = match self.find_storage(storage_id) { + Some(s) => s, + None => continue, + }; + let full_path = storage.config.backing_dir.join(&obj.rel_path); + if full_path.is_dir() { + let children = self.scan_dir(storage_id, handle)?; + all_handles.extend(&children); + queue.extend(children); + } + } + + Ok(all_handles) + } + + /// Resolve the full filesystem path for an object handle. + pub fn resolve_path(&self, handle: ObjectHandle) -> Option { + let obj = self.objects.get(&handle.0)?; + let storage = self.find_storage(obj.storage_id)?; + Some(storage.config.backing_dir.join(&obj.rel_path)) + } + + /// Check if a storage is read-only. + pub fn is_read_only(&self, storage_id: StorageId) -> bool { + self.find_storage(storage_id) + .map(|s| s.config.read_only) + .unwrap_or(false) + } + + /// Force a full rescan of all backing directories, syncing the in-memory + /// object tree with the actual filesystem state. + /// + /// This diffs the current `objects` map against the filesystem: + /// - **Stale entries** (objects whose `rel_path` no longer exists on disk) are removed. + /// - **New entries** (files on disk not tracked in `objects`) are added. + /// + /// Appropriate `ObjectAdded`, `ObjectRemoved`, and `StorageInfoChanged` + /// events are queued for each change so MTP clients can pick them up + /// via `receive_interrupt()`. + /// + /// Use this when you've manipulated the backing directories directly + /// (for example, resetting test fixtures) and need the virtual device to + /// reflect the changes immediately, without waiting for the filesystem + /// watcher's latency. + pub fn rescan_backing_dirs(&mut self) -> RescanSummary { + let mut added = 0u32; + let mut removed = 0u32; + let mut changed_storages = HashSet::new(); + + // Phase 1: Remove stale entries (objects whose files no longer exist on disk). + let stale_handles: Vec<(u32, StorageId)> = self + .objects + .iter() + .filter_map(|(&handle, obj)| { + let storage = self + .storages + .iter() + .find(|s| s.storage_id == obj.storage_id)?; + let full_path = storage.config.backing_dir.join(&obj.rel_path); + if !full_path.exists() { + Some((handle, obj.storage_id)) + } else { + None + } + }) + .collect(); + + for (handle, storage_id) in stale_handles { + self.objects.remove(&handle); + self.event_queue + .push_back(build_event(EventCode::ObjectRemoved, &[handle])); + changed_storages.insert(storage_id); + removed += 1; + } + + // Phase 2: Add new entries by scanning all storages recursively. + // Collect storage info upfront to avoid borrow conflicts. + let storage_info: Vec<(StorageId, PathBuf)> = self + .storages + .iter() + .map(|s| (s.storage_id, s.config.backing_dir.clone())) + .collect(); + + for (storage_id, backing_dir) in &storage_info { + let storage_added = + self.rescan_dir(*storage_id, backing_dir, backing_dir, ObjectHandle::ROOT); + added += storage_added; + if storage_added > 0 { + changed_storages.insert(*storage_id); + } + } + + // Phase 3: Queue StorageInfoChanged for each affected storage. + for storage_id in &changed_storages { + self.event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); + } + + RescanSummary { added, removed } + } + + /// Recursively scan a directory and add any entries not already tracked. + /// Returns the number of newly added objects. + fn rescan_dir( + &mut self, + storage_id: StorageId, + backing_dir: &std::path::Path, + dir_path: &std::path::Path, + parent: ObjectHandle, + ) -> u32 { + let entries = match std::fs::read_dir(dir_path) { + Ok(e) => e, + Err(_) => return 0, + }; + + let mut added = 0u32; + + for entry in entries.flatten() { + let full_path = entry.path(); + let rel_path = match full_path.strip_prefix(backing_dir) { + Ok(r) => r.to_path_buf(), + Err(_) => continue, + }; + + // Check if already tracked. + let existing = self + .objects + .iter() + .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path) + .map(|(&h, _)| ObjectHandle(h)); + + let handle = if let Some(h) = existing { + h + } else { + let h = self.alloc_handle(); + self.objects.insert( + h.0, + VirtualObject { + rel_path: rel_path.clone(), + storage_id, + parent, + }, + ); + self.event_queue + .push_back(build_event(EventCode::ObjectAdded, &[h.0])); + added += 1; + h + }; + + // Recurse into directories. + if full_path.is_dir() { + added += self.rescan_dir(storage_id, backing_dir, &full_path, handle); + } + } + + added + } +} + +/// Summary of changes made by a rescan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RescanSummary { + /// Number of new objects added to the object tree. + pub added: u32, + /// Number of stale objects removed from the object tree. + pub removed: u32, +} diff --git a/vendor/mtp-rs/src/transport/virtual_device/watcher.rs b/vendor/mtp-rs/src/transport/virtual_device/watcher.rs new file mode 100644 index 0000000..c164612 --- /dev/null +++ b/vendor/mtp-rs/src/transport/virtual_device/watcher.rs @@ -0,0 +1,239 @@ +//! Filesystem watcher for detecting out-of-band changes to backing directories. +//! +//! When files are written directly to a virtual device's backing directory (bypassing +//! MTP protocol operations), this watcher detects the changes and queues the +//! corresponding MTP events (`ObjectAdded`, `ObjectRemoved`). +//! +//! ## Dedup strategy +//! +//! MTP protocol handlers (upload, delete, move, etc.) modify the filesystem while +//! holding the `state` mutex and insert object handles into `state.objects` before +//! releasing the lock. The watcher callback also acquires `state` before processing +//! events. This mutex serialization guarantees that by the time the watcher sees a +//! filesystem notification for an MTP-initiated change, the state already reflects it: +//! +//! - **Creates**: the handle already exists in `state.objects` → watcher skips the event. +//! - **Removes**: the handle has already been removed from `state.objects` → watcher +//! finds no handle for the path → nothing to emit. +//! +//! No TTL, no extra tracking structure — the state itself is the dedup mechanism. +//! +//! ## Pause/resume +//! +//! The watcher can be paused via [`pause_watcher`](super::registry::pause_watcher), +//! which returns a [`WatcherGuard`](super::registry::WatcherGuard) that resumes +//! on drop. While paused, all filesystem events are silently dropped. This is +//! needed when external code deletes and recreates files in the backing directory: +//! without pausing, the watcher may process stale deletion events after a rescan +//! has already added the new objects, incorrectly removing them. + +use super::builders::build_event; +use super::state::VirtualDeviceState; +use crate::ptp::{EventCode, ObjectHandle, StorageId}; +use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::sync::Mutex; + +/// Mapping from backing dir to its storage ID, used by the watcher callback. +type StorageMap = Vec<(PathBuf, StorageId)>; + +/// Start a filesystem watcher for all backing directories. +/// +/// Returns the watcher (must be kept alive) or `None` if starting fails. +/// The watcher pushes events into `state.event_queue` via the shared mutex. +pub(super) fn start_fs_watcher( + state: &std::sync::Arc>, +) -> Option { + let state_clone = std::sync::Arc::clone(state); + + // Build a map of backing_dir → storage_id for resolving events. + let storage_map: StorageMap = { + let s = state.lock().unwrap(); + s.storages + .iter() + .map(|ss| { + let dir = ss + .config + .backing_dir + .canonicalize() + .unwrap_or_else(|_| ss.config.backing_dir.clone()); + (dir, ss.storage_id) + }) + .collect() + }; + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + let event = match res { + Ok(e) => e, + Err(_) => return, + }; + + handle_notify_event(&state_clone, &storage_map, event); + }, + Config::default(), + ) + .ok()?; + + // Watch all backing directories recursively. + let state_lock = state.lock().unwrap(); + for storage in &state_lock.storages { + let _ = watcher.watch(&storage.config.backing_dir, RecursiveMode::Recursive); + } + drop(state_lock); + + Some(watcher) +} + +/// Process a single notify event and queue MTP events if appropriate. +fn handle_notify_event( + state: &std::sync::Arc>, + storage_map: &StorageMap, + event: notify::Event, +) { + // Only handle create, remove, and rename events. + let is_create = matches!( + event.kind, + EventKind::Create(_) + | EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::To + )) + ); + let is_remove = matches!( + event.kind, + EventKind::Remove(_) + | EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::From + )) + ); + // macOS FSEvents can't distinguish rename source from target, so it reports + // RenameMode::Any. We determine the direction by checking if the path exists. + let is_rename_any = matches!( + event.kind, + EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::Any + )) + ); + + if !is_create && !is_remove && !is_rename_any { + return; + } + + for path in &event.paths { + // Canonicalize for reliable comparison (ignore errors for removed paths). + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + + // For RenameMode::Any, determine direction from whether the path exists. + let (is_create, _is_remove) = if is_rename_any { + if path.exists() { + (true, false) + } else { + (false, true) + } + } else { + (is_create, is_remove) + }; + + // Find which storage this path belongs to. + let (storage_id, backing_dir) = match find_storage_for_path(&canonical, storage_map) { + Some(v) => v, + None => continue, + }; + + // Compute relative path within the storage. + let rel_path = match canonical.strip_prefix(backing_dir) { + Ok(r) => r.to_path_buf(), + Err(_) => continue, + }; + + // Skip events for the backing directory itself (empty rel_path). + // macOS FSEvents reports the watched directory as "created" on startup. + if rel_path.as_os_str().is_empty() { + continue; + } + + let mut state = state.lock().unwrap(); + + if state.watcher_paused { + continue; + } + + if is_create { + // Check if a handle already exists for this path. If so, the MTP handler + // already created it and emitted events — skip to avoid duplicates. + let already_known = state + .objects + .iter() + .any(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path); + + if already_known { + continue; + } + + // Determine the parent handle. + let parent = + if let Some(parent_rel) = rel_path.parent() { + if parent_rel == std::path::Path::new("") { + ObjectHandle::ROOT + } else { + // Look up the parent object. + match state.objects.iter().find(|(_, obj)| { + obj.storage_id == storage_id && obj.rel_path == parent_rel + }) { + Some((&h, _)) => ObjectHandle(h), + None => ObjectHandle::ROOT, + } + } + } else { + ObjectHandle::ROOT + }; + + let handle = state.alloc_handle(); + state.objects.insert( + handle.0, + super::state::VirtualObject { + rel_path, + storage_id, + parent, + }, + ); + + state + .event_queue + .push_back(build_event(EventCode::ObjectAdded, &[handle.0])); + state + .event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); + } else { + // Remove: find the handle for this path and remove it. + // If the handle is already gone, the MTP handler already processed + // the removal — skip to avoid duplicates. + let handle = state + .objects + .iter() + .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path) + .map(|(&h, _)| h); + + if let Some(h) = handle { + state.objects.remove(&h); + state + .event_queue + .push_back(build_event(EventCode::ObjectRemoved, &[h])); + state + .event_queue + .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); + } + } + } +} + +/// Find which storage a path belongs to by checking if it's under a backing dir. +fn find_storage_for_path<'a>( + path: &std::path::Path, + storage_map: &'a StorageMap, +) -> Option<(StorageId, &'a PathBuf)> { + storage_map + .iter() + .find(|(dir, _)| path.starts_with(dir)) + .map(|(dir, sid)| (*sid, dir)) +} From bc2f8b38ad94c59dba52959f0ef06ebcd4617cf8 Mon Sep 17 00:00:00 2001 From: luke miles Date: Fri, 28 Aug 2026 09:03:53 -0400 Subject: [PATCH 2/3] chore(deps): use upstream mtp-rs 0.32.0 --- Cargo.lock | 206 ++- Cargo.toml | 2 +- docs/spec.md | 11 +- docs/tp7-handshake.md | 18 +- src/ls.rs | 2 +- src/mtp_session.rs | 26 +- src/pull.rs | 6 +- src/push.rs | 7 +- src/remote.rs | 83 +- src/stat.rs | 52 +- src/status.rs | 14 +- src/tree.rs | 19 +- src/write_ops.rs | 15 +- vendor/mtp-rs/Cargo.toml | 154 -- vendor/mtp-rs/LICENSE-APACHE | 201 --- vendor/mtp-rs/LICENSE-MIT | 21 - vendor/mtp-rs/README.md | 466 ------ vendor/mtp-rs/TP7-PATCH.md | 12 - vendor/mtp-rs/src/error.rs | 176 --- vendor/mtp-rs/src/lib.rs | 69 - vendor/mtp-rs/src/mtp/device.rs | 728 ---------- vendor/mtp-rs/src/mtp/event.rs | 182 --- vendor/mtp-rs/src/mtp/mod.rs | 45 - vendor/mtp-rs/src/mtp/object.rs | 194 --- vendor/mtp-rs/src/mtp/storage.rs | 1112 -------------- vendor/mtp-rs/src/mtp/stream.rs | 300 ---- vendor/mtp-rs/src/ptp/codes.rs | 660 --------- vendor/mtp-rs/src/ptp/container.rs | 724 --------- vendor/mtp-rs/src/ptp/device.rs | 268 ---- vendor/mtp-rs/src/ptp/mod.rs | 155 -- vendor/mtp-rs/src/ptp/pack/datetime.rs | 549 ------- vendor/mtp-rs/src/ptp/pack/mod.rs | 534 ------- vendor/mtp-rs/src/ptp/session/mod.rs | 889 ------------ vendor/mtp-rs/src/ptp/session/operations.rs | 798 ---------- vendor/mtp-rs/src/ptp/session/properties.rs | 354 ----- vendor/mtp-rs/src/ptp/session/streaming.rs | 657 --------- vendor/mtp-rs/src/ptp/test_utils.rs | 82 -- vendor/mtp-rs/src/ptp/types/device.rs | 513 ------- vendor/mtp-rs/src/ptp/types/mod.rs | 21 - vendor/mtp-rs/src/ptp/types/objects.rs | 595 -------- vendor/mtp-rs/src/ptp/types/properties.rs | 772 ---------- vendor/mtp-rs/src/ptp/types/storage.rs | 243 ---- vendor/mtp-rs/src/transport/mock.rs | 301 ---- vendor/mtp-rs/src/transport/mod.rs | 72 - vendor/mtp-rs/src/transport/nusb.rs | 777 ---------- .../src/transport/virtual_device/CLAUDE.md | 48 - .../src/transport/virtual_device/builders.rs | 260 ---- .../src/transport/virtual_device/config.rs | 69 - .../src/transport/virtual_device/handlers.rs | 1096 -------------- .../src/transport/virtual_device/mod.rs | 1290 ----------------- .../src/transport/virtual_device/registry.rs | 378 ----- .../src/transport/virtual_device/state.rs | 367 ----- .../src/transport/virtual_device/watcher.rs | 239 --- 53 files changed, 333 insertions(+), 16499 deletions(-) delete mode 100644 vendor/mtp-rs/Cargo.toml delete mode 100644 vendor/mtp-rs/LICENSE-APACHE delete mode 100644 vendor/mtp-rs/LICENSE-MIT delete mode 100644 vendor/mtp-rs/README.md delete mode 100644 vendor/mtp-rs/TP7-PATCH.md delete mode 100644 vendor/mtp-rs/src/error.rs delete mode 100644 vendor/mtp-rs/src/lib.rs delete mode 100644 vendor/mtp-rs/src/mtp/device.rs delete mode 100644 vendor/mtp-rs/src/mtp/event.rs delete mode 100644 vendor/mtp-rs/src/mtp/mod.rs delete mode 100644 vendor/mtp-rs/src/mtp/object.rs delete mode 100644 vendor/mtp-rs/src/mtp/storage.rs delete mode 100644 vendor/mtp-rs/src/mtp/stream.rs delete mode 100644 vendor/mtp-rs/src/ptp/codes.rs delete mode 100644 vendor/mtp-rs/src/ptp/container.rs delete mode 100644 vendor/mtp-rs/src/ptp/device.rs delete mode 100644 vendor/mtp-rs/src/ptp/mod.rs delete mode 100644 vendor/mtp-rs/src/ptp/pack/datetime.rs delete mode 100644 vendor/mtp-rs/src/ptp/pack/mod.rs delete mode 100644 vendor/mtp-rs/src/ptp/session/mod.rs delete mode 100644 vendor/mtp-rs/src/ptp/session/operations.rs delete mode 100644 vendor/mtp-rs/src/ptp/session/properties.rs delete mode 100644 vendor/mtp-rs/src/ptp/session/streaming.rs delete mode 100644 vendor/mtp-rs/src/ptp/test_utils.rs delete mode 100644 vendor/mtp-rs/src/ptp/types/device.rs delete mode 100644 vendor/mtp-rs/src/ptp/types/mod.rs delete mode 100644 vendor/mtp-rs/src/ptp/types/objects.rs delete mode 100644 vendor/mtp-rs/src/ptp/types/properties.rs delete mode 100644 vendor/mtp-rs/src/ptp/types/storage.rs delete mode 100644 vendor/mtp-rs/src/transport/mock.rs delete mode 100644 vendor/mtp-rs/src/transport/mod.rs delete mode 100644 vendor/mtp-rs/src/transport/nusb.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/builders.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/config.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/handlers.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/mod.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/registry.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/state.rs delete mode 100644 vendor/mtp-rs/src/transport/virtual_device/watcher.rs diff --git a/Cargo.lock b/Cargo.lock index 7a76679..0938c35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,12 +84,24 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -374,6 +386,17 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.186" @@ -409,7 +432,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mtp-rs" -version = "0.13.3" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5512a899d3ad31f804bd0f85108ad7256e6f5414030ee3c247c4ba2dffa116b0" dependencies = [ "async-trait", "bytes", @@ -418,6 +443,8 @@ dependencies = [ "num_enum", "nusb", "thiserror", + "windows", + "windows-core", ] [[package]] @@ -444,19 +471,23 @@ dependencies = [ [[package]] name = "nusb" -version = "0.2.3" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a330b3bc7f8b4fc729a4c63164b3927eeeaced198222a3ce6b8b6e034851b7a" +checksum = "18ef13beb3b3a8fc16fd7aea912ebd3d45dde00a9a5b968d0742297468065845" dependencies = [ "core-foundation", "core-foundation-sys", "futures-core", "io-kit-sys", + "js-sys", "linux-raw-sys", "log", "once_cell", "rustix", "slab", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "windows-sys", ] @@ -723,12 +754,172 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -738,6 +929,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index a70fb9a..1a2a3bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" futures = "0.3" log = "0.4.29" -mtp-rs = { path = "vendor/mtp-rs" } +mtp-rs = "0.32.0" nusb = "0.2.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/docs/spec.md b/docs/spec.md index 1f773d0..3657746 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -212,7 +212,7 @@ Open validation tasks: - Confirmed: `mtp-rs` can open the TP-7 once it is in MTP mode. - Confirm object listing and transfers work with TP-7 recordings. -- Confirm whether `mtp-rs` exposes all required operations or whether we need small extensions. +- Confirmed: `mtp-rs` 0.32.0 includes the TP-7 session-reuse API upstream, so no vendored fork is required. - Confirm whether `nusb` can handle any macOS device ownership edge cases cleanly. Validated MTP status from `mtp-rs`: @@ -227,6 +227,11 @@ Validated MTP status from `mtp-rs`: - Capacity: `120244404224` bytes - Free space observed: `112986914816` bytes +`mtp-rs` 0.32.0 moved its high-level API to backend-neutral device and storage +metadata. The CLI keeps the existing JSON keys for vendor-extension text and +free object counts, but reports them as `null` because those protocol-specific +values are no longer exposed by that API. + ## Go Tooling Considered Go is feasible but less attractive for this project. @@ -517,7 +522,7 @@ MTP semantics: Library risk: -- `mtp-rs` may need fixes or extensions for TP-7 quirks. +- Future TP-7 quirks may still require upstream `mtp-rs` fixes or extensions. - `libmtp` fallback is likely reliable but adds C dependency and licensing considerations. Mount risk: @@ -531,7 +536,7 @@ Mount risk: - Teenage Engineering TP-7 product page: - Teenage Engineering TP-7 downloads/release notes: - Microsoft MTP extensions overview: -- `mtp-rs` crate docs: +- `mtp-rs` crate docs: - `fuser` crate docs: - macFUSE: - Fuse-T: diff --git a/docs/tp7-handshake.md b/docs/tp7-handshake.md index 946d41a..8c6f0f1 100644 --- a/docs/tp7-handshake.md +++ b/docs/tp7-handshake.md @@ -148,12 +148,14 @@ What each step taught us: traffic while idle. Its stable pre-list initialization reads the battery descriptor, refreshes storage metadata twice, reads battery level twice and device time once, then refreshes storage metadata before listing objects. - A smaller prefix remained stable only until object listing began. The CLI now - performs the full pre-list initialization, uses FieldKit's stable session ID, - reuses `SessionAlreadyOpen` without sending `CloseSession`, and leaves USB - cleanup to process exit. Ordinary commands therefore leave the TP-7 in MTP - without polling or a resident process; `tp7 eject` remains the explicit - protocol-level close. + A smaller prefix remained stable only until object listing began. The CLI + uses FieldKit's stable session ID through `mtp-rs` 0.32.0, reuses + `SessionAlreadyOpen` without sending `CloseSession`, primes storage metadata, + and leaves USB cleanup to process exit. Ordinary commands therefore leave + the TP-7 in MTP without polling or a resident process; `tp7 eject` remains + the explicit protocol-level close. The FieldKit property reads remain useful + reverse-engineering evidence, but are not exposed by the backend-neutral + high-level API in `mtp-rs` 0.32.0. - `ls --take-over` automates that recovery narrowly: it acts only after an exclusive-access failure and only when IORegistry identifies `ptpcamerad` as the exclusive TP-7 MTP interface owner. It leaves every other owner alone. @@ -163,8 +165,8 @@ What each step taught us: - Newer firmware accepts mode payload `[0x01, 0x03]`. FieldKit suggests `[0x01, 0x02]` may be a fallback for older firmware. - MTP support lives behind our own session layer. `ls`, `tree`, `stat`, and - `pull`, `push`, `rename`, and `rm` now use that same switch/open/work/close - flow. + `pull`, `push`, `rename`, and `rm` now use the same switch/open/work/release + flow, backed by the upstream `mtp-rs` 0.32.0 session-reuse API. - TP-7 firmware `1.1.9` accepted file upload, rename, and delete in smoke tests, but rejected folder creation with MTP `GeneralError`. - `push --overwrite` stages a replacement under a temporary remote name before diff --git a/src/ls.rs b/src/ls.rs index cb347b1..af88da6 100644 --- a/src/ls.rs +++ b/src/ls.rs @@ -7,7 +7,7 @@ use crate::remote::{RemoteObject, RemoteTarget, first_storage, list_remote_objec #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LsReport { pub path: String, - pub storage_id: u32, + pub storage_id: u64, pub entries: Vec, } diff --git a/src/mtp_session.rs b/src/mtp_session.rs index a059dcc..2a71aa0 100644 --- a/src/mtp_session.rs +++ b/src/mtp_session.rs @@ -4,7 +4,6 @@ use std::thread; use std::time::{Duration, Instant}; use mtp_rs::MtpDevice; -use mtp_rs::ptp::DevicePropertyCode; use crate::device::{ TP7_MTP_PRODUCT_ID, TP7_VENDOR_ID, Tp7Device, UsbMode, list_tp7_devices, select_one_device, @@ -215,16 +214,14 @@ pub async fn open_mtp_device(serial: Option<&str>) -> Result { MtpDevice::builder() .known_devices(KNOWN_TP7) - .session_id(TP7_SESSION_ID) - .reuse_existing_session(true) + .reuse_existing_session(TP7_SESSION_ID) .open_by_serial(serial) .await } None => { MtpDevice::builder() .known_devices(KNOWN_TP7) - .session_id(TP7_SESSION_ID) - .reuse_existing_session(true) + .reuse_existing_session(TP7_SESSION_ID) .open_first() .await } @@ -237,26 +234,7 @@ pub async fn open_mtp_device(serial: Option<&str>) -> Result Result<(), AppError> { - let session = device.session(); - - session - .get_device_prop_desc(DevicePropertyCode::BatteryLevel) - .await - .map_err(map_mtp_error)?; - device.storages().await.map_err(map_mtp_error)?; device.storages().await.map_err(map_mtp_error)?; - session - .get_device_prop_value(DevicePropertyCode::BatteryLevel) - .await - .map_err(map_mtp_error)?; - session - .get_device_prop_value(DevicePropertyCode::BatteryLevel) - .await - .map_err(map_mtp_error)?; - session - .get_device_prop_value(DevicePropertyCode::DateTime) - .await - .map_err(map_mtp_error)?; Ok(()) } diff --git a/src/pull.rs b/src/pull.rs index 8fd2c83..dae9b6a 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -2,9 +2,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use mtp_rs::Storage; -use mtp_rs::ptp::ObjectInfo; -use mtp_rs::{DEFAULT_CANCEL_TIMEOUT, ObjectHandle}; +use mtp_rs::{ByteRange, DEFAULT_CANCEL_TIMEOUT, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -249,7 +247,7 @@ async fn download_to_path( options: PullOptions, ) -> Result<(), AppError> { let mut download = storage - .download_stream(handle) + .download(handle, ByteRange::Full) .await .map_err(map_mtp_error)?; let mut file = create_file(local_path)?; diff --git a/src/push.rs b/src/push.rs index b3283dd..d5f3639 100644 --- a/src/push.rs +++ b/src/push.rs @@ -8,8 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bytes::Bytes; use futures::Stream; -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; -use mtp_rs::{NewObjectInfo, Storage}; +use mtp_rs::{NewObjectInfo, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -346,7 +345,7 @@ async fn upload_file( ControlFlow::Continue(()) }) .await - .map_err(map_mtp_error); + .map_err(|error| map_mtp_error(error.into())); finish_progress(options.progress); result @@ -689,7 +688,7 @@ impl Stream for FileChunkStream { #[cfg(test)] mod tests { - use mtp_rs::ptp::ObjectHandle; + use mtp_rs::ObjectHandle; use super::*; diff --git a/src/remote.rs b/src/remote.rs index 93ed11c..26ade99 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,5 +1,4 @@ -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; -use mtp_rs::{MtpDevice, Storage}; +use mtp_rs::{DateTime, MtpDevice, ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::map_mtp_error; @@ -7,9 +6,9 @@ use crate::output::AppError; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RemoteObject { - pub id: u32, - pub parent_id: u32, - pub storage_id: u32, + pub id: u64, + pub parent_id: u64, + pub storage_id: u64, pub kind: ObjectKind, pub name: String, pub size: u64, @@ -46,11 +45,23 @@ impl RemoteObject { }, name: object.filename, size: object.size, - modified: object.modified.and_then(|modified| modified.format()), + modified: object.modified.map(format_datetime), } } } +pub fn format_datetime(datetime: DateTime) -> String { + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}", + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second + ) +} + pub async fn first_storage(device: &MtpDevice) -> Result { let mut storages = device.storages().await.map_err(map_mtp_error)?; @@ -214,7 +225,7 @@ fn kind_sort_key(kind: &ObjectKind) -> u8 { #[cfg(test)] mod tests { - use mtp_rs::ptp::{DateTime, ObjectFormatCode, ObjectHandle, ObjectInfo, StorageId}; + use mtp_rs::{DateTime, ObjectFormat, ObjectHandle, ObjectInfo, StorageId}; use super::*; @@ -241,15 +252,20 @@ mod tests { #[test] fn converts_object_info_to_remote_object() { - let object = ObjectInfo { - handle: ObjectHandle(42), - storage_id: StorageId(65_537), - parent: ObjectHandle(7), - filename: "take.wav".to_string(), - size: 1024, - modified: Some(DateTime::new(2026, 5, 7, 12, 30, 45).unwrap()), - ..Default::default() - }; + let mut object = ObjectInfo::default(); + object.handle = ObjectHandle(42); + object.storage_id = StorageId(65_537); + object.parent = ObjectHandle(7); + object.filename = "take.wav".to_string(); + object.size = 1024; + object.modified = Some(DateTime { + year: 2026, + month: 5, + day: 7, + hour: 12, + minute: 30, + second: 45, + }); let entry = RemoteObject::from_object(object); @@ -282,12 +298,8 @@ mod tests { } #[test] - fn sorts_object_infos_folders_before_files_by_name() { - let mut objects = vec![ - object("z.wav", ObjectKind::File), - object("Recordings", ObjectKind::Folder), - object("a.wav", ObjectKind::File), - ]; + fn sorts_object_infos_by_name() { + let mut objects = vec![object("z.wav"), object("Recordings"), object("a.wav")]; sort_object_infos(&mut objects); @@ -296,7 +308,7 @@ mod tests { .into_iter() .map(|object| object.filename) .collect::>(), - vec!["Recordings", "a.wav", "z.wav"] + vec!["a.wav", "Recordings", "z.wav"] ); } @@ -314,18 +326,21 @@ mod tests { } fn entry(name: &str, kind: ObjectKind) -> RemoteObject { - RemoteObject::from_object(object(name, kind)) + RemoteObject { + id: 0, + parent_id: 0, + storage_id: 0, + kind, + name: name.to_string(), + size: 0, + modified: None, + } } - fn object(name: &str, kind: ObjectKind) -> ObjectInfo { - let format = match kind { - ObjectKind::File => ObjectFormatCode::Wav, - ObjectKind::Folder => ObjectFormatCode::Association, - }; - ObjectInfo { - filename: name.to_string(), - format, - ..Default::default() - } + fn object(name: &str) -> ObjectInfo { + let mut object = ObjectInfo::default(); + object.filename = name.to_string(); + object.format = ObjectFormat(0x3008); + object } } diff --git a/src/stat.rs b/src/stat.rs index f871982..e09ca02 100644 --- a/src/stat.rs +++ b/src/stat.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; use crate::output::AppError; -use crate::remote::{RemoteObject, RemoteTarget, first_storage, resolve_path}; +use crate::remote::{RemoteObject, RemoteTarget, first_storage, format_datetime, resolve_path}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatReport { @@ -44,9 +44,12 @@ async fn read_stat(device: &mtp_rs::MtpDevice, remote_path: &str) -> Result Result, pub supports_rename: bool, pub storage_count: usize, pub storages: Vec, @@ -26,12 +26,12 @@ pub struct MtpStatus { #[derive(Debug, Clone, Serialize)] pub struct StorageStatus { - pub id: u32, + pub id: u64, pub description: String, pub volume_identifier: String, pub max_capacity_bytes: u64, pub free_space_bytes: u64, - pub free_space_objects: u32, + pub free_space_objects: Option, } pub fn run_status(serial: Option<&str>) -> Result { @@ -57,9 +57,9 @@ pub async fn read_mtp_status(device: &MtpDevice) -> Result id: storage.id().0, description: info.description.clone(), volume_identifier: info.volume_identifier.clone(), - max_capacity_bytes: info.max_capacity, - free_space_bytes: info.free_space_bytes, - free_space_objects: info.free_space_objects, + max_capacity_bytes: info.total_capacity, + free_space_bytes: info.free_space, + free_space_objects: None, } }) .collect(); @@ -69,7 +69,7 @@ pub async fn read_mtp_status(device: &MtpDevice) -> Result model: device_info.model, device_version: device_info.device_version, serial_number: device_info.serial_number, - vendor_extension: device_info.vendor_extension_desc, + vendor_extension: None, supports_rename: device.supports_rename(), storage_count, storages, diff --git a/src/tree.rs b/src/tree.rs index a1018c7..88e2f80 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,5 +1,4 @@ -use mtp_rs::Storage; -use mtp_rs::ptp::{ObjectHandle, ObjectInfo}; +use mtp_rs::{ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, open_mtp_session}; @@ -10,7 +9,7 @@ use crate::remote::{RemoteObject, RemoteTarget, first_storage, list_object_infos #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TreeReport { pub path: String, - pub storage_id: u32, + pub storage_id: u64, pub entries: Vec, } @@ -135,7 +134,7 @@ impl TreeEntry { #[cfg(test)] mod tests { - use mtp_rs::ptp::{ObjectFormatCode, ObjectHandle, ObjectInfo}; + use mtp_rs::{ObjectFormat, ObjectHandle, ObjectInfo}; use crate::remote::ObjectKind; @@ -143,13 +142,11 @@ mod tests { #[test] fn builds_tree_entry_from_object() { - let object = ObjectInfo { - handle: ObjectHandle(16), - format: ObjectFormatCode::Wav, - filename: "take.wav".to_string(), - size: 1024, - ..Default::default() - }; + let mut object = ObjectInfo::default(); + object.handle = ObjectHandle(16); + object.format = ObjectFormat(0x3008); + object.filename = "take.wav".to_string(); + object.size = 1024; let entry = TreeEntry::from_object("/recordings/take.wav".to_string(), 1, object); diff --git a/src/write_ops.rs b/src/write_ops.rs index 7425443..f89f98d 100644 --- a/src/write_ops.rs +++ b/src/write_ops.rs @@ -1,6 +1,4 @@ -use mtp_rs::ResponseCode; -use mtp_rs::Storage; -use mtp_rs::ptp::ObjectHandle; +use mtp_rs::{ObjectHandle, ObjectInfo, Storage}; use serde::{Deserialize, Serialize}; use crate::mtp_session::{MtpOpenPolicy, block_on, map_mtp_error, open_mtp_session}; @@ -227,7 +225,7 @@ async fn find_child( storage: &Storage, parent: Option, name: &str, -) -> Result, AppError> { +) -> Result, AppError> { Ok(list_object_infos(storage, parent) .await? .into_iter() @@ -270,13 +268,8 @@ fn renamed_path(old_path: &str, new_name: &str) -> String { } fn map_create_folder_error(error: mtp_rs::Error) -> AppError { - match error.response_code() { - Some( - ResponseCode::GeneralError - | ResponseCode::OperationNotSupported - | ResponseCode::StoreReadOnly - | ResponseCode::AccessDenied, - ) => AppError::MtpUnsupported { + match error { + mtp_rs::Error::Unsupported | mtp_rs::Error::AccessDenied => AppError::MtpUnsupported { message: format!("TP-7 rejected folder creation ({error})"), }, _ => map_mtp_error(error), diff --git a/vendor/mtp-rs/Cargo.toml b/vendor/mtp-rs/Cargo.toml deleted file mode 100644 index a94c662..0000000 --- a/vendor/mtp-rs/Cargo.toml +++ /dev/null @@ -1,154 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2021" -rust-version = "1.85" -name = "mtp-rs" -version = "0.13.3" -build = false -exclude = [ - ".github/", - "AGENTS.md", - "docs/", - "justfile", - "deny.toml", - "clippy.toml", - "rustfmt.toml", - "proptest-regressions/", -] -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Pure-Rust MTP (Media Transfer Protocol) library for modern Android devices" -documentation = "https://docs.rs/mtp-rs" -readme = "README.md" -keywords = [ - "mtp", - "android", - "usb", - "media", - "transfer", -] -categories = [ - "hardware-support", - "filesystem", -] -license = "MIT OR Apache-2.0" -repository = "https://github.com/vdavid/mtp-rs" - -[package.metadata.docs.rs] -all-features = true - -[features] -default = [] -virtual-device = ["notify"] - -[lib] -name = "mtp_rs" -path = "src/lib.rs" - -[[example]] -name = "debug_listing" -path = "examples/debug_listing.rs" - -[[example]] -name = "debug_pixel_slow" -path = "examples/debug_pixel_slow.rs" - -[[example]] -name = "diagnose" -path = "examples/diagnose.rs" - -[[example]] -name = "fuji_capture" -path = "examples/fuji_capture.rs" - -[[example]] -name = "fuji_rw_check" -path = "examples/fuji_rw_check.rs" - -[[example]] -name = "list_all_usb" -path = "examples/list_all_usb.rs" - -[[example]] -name = "list_and_download" -path = "examples/list_and_download.rs" - -[[example]] -name = "ptp_diagnose" -path = "examples/ptp_diagnose.rs" - -[[example]] -name = "test_capture" -path = "examples/test_capture.rs" - -[[example]] -name = "test_file_ops" -path = "examples/test_file_ops.rs" - -[[example]] -name = "test_partial_download_64" -path = "examples/test_partial_download_64.rs" - -[[test]] -name = "integration" -path = "tests/integration.rs" - -[dependencies.async-trait] -version = "0.1" - -[dependencies.bytes] -version = "1.5" - -[dependencies.futures] -version = "0.3" - -[dependencies.futures-timer] -version = "3.0" - -[dependencies.notify] -version = "8" -optional = true - -[dependencies.num_enum] -version = "0.7" - -[dependencies.nusb] -version = "0.2" - -[dependencies.thiserror] -version = "2" - -[dev-dependencies.parking_lot] -version = "0.12" - -[dev-dependencies.proptest] -version = "1" - -[dev-dependencies.serial_test] -version = "3.1" - -[dev-dependencies.tempfile] -version = "3" - -[dev-dependencies.tokio] -version = "1.35" -features = [ - "rt-multi-thread", - "macros", - "time", - "fs", - "io-util", -] diff --git a/vendor/mtp-rs/LICENSE-APACHE b/vendor/mtp-rs/LICENSE-APACHE deleted file mode 100644 index 2251a4c..0000000 --- a/vendor/mtp-rs/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright 2026 David Veszelovszki - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/vendor/mtp-rs/LICENSE-MIT b/vendor/mtp-rs/LICENSE-MIT deleted file mode 100644 index cc09e8b..0000000 --- a/vendor/mtp-rs/LICENSE-MIT +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 David Veszelovszki - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/mtp-rs/README.md b/vendor/mtp-rs/README.md deleted file mode 100644 index 0a15352..0000000 --- a/vendor/mtp-rs/README.md +++ /dev/null @@ -1,466 +0,0 @@ -# mtp-rs - -[![Crates.io](https://img.shields.io/crates/v/mtp-rs)](https://crates.io/crates/mtp-rs) -[![docs.rs](https://img.shields.io/docsrs/mtp-rs)](https://docs.rs/mtp-rs) -[![CI](https://github.com/vdavid/mtp-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/vdavid/mtp-rs/actions/workflows/ci.yml) -[![License](https://img.shields.io/crates/l/mtp-rs)](LICENSE-MIT) -[![MSRV](https://img.shields.io/badge/MSRV-1.85-blue)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html) - -A pure-Rust, async MTP/PTP library. -No C dependencies, consistently faster than libmtp (up to 4x for large transfers), and way more predictable. - -Talk to Android phones, e-book readers incl. Kindle, and digital cameras over USB. -No `libmtp`, no `libusb`, no FFI, just async Rust built on [`nusb`](https://crates.io/crates/nusb). - -**Why this matters:** - -- Cross-compile without system lib headaches -- No `pkg-config`, no `-sys` crates, no `build.rs` surprises -- Works anywhere Rust compiles (including `musl` and cross-compilation targets) -- Fully async and runtime-agnostic -- [Virtual device mode](#virtual-device-testing-without-hardware) for testing without a USB device plugged in - -## What it does - -- Connect to devices over USB -- List, download, upload, delete, move, copy, and rename files -- Create, delete, and rename folders -- Stream large file downloads and uploads with continued progress indication -- Listen for device events (file added, storage removed, etc.) -- See free space -- Also exposes a lower-level interface for PTP, so it can be used for cameras too. - -## What it doesn't do - -- MTPZ (the DRM extension some old devices used) -- Playlists, tracks, albums, and custom operations -- Vendor-specific extensions -- Legacy Android device quirks (pre-5.0 devices) - -We intentionally didn't want to support these because they're rarely needed now, and it'd be a nightmare to test. -[libmtp](https://github.com/libmtp/libmtp/) has an impressive collection of device quirks, but it's LGPL-1.1 licensed, -and I wanted to do MIT/Apache-2.0 for broader access. So copying that code was also not an option. - -## Quick start - -A simple test would be this: - -```rust -use mtp_rs::mtp::MtpDevice; - -#[tokio::main] -async fn main() -> Result<(), mtp_rs::Error> { - // Connect to the first MTP device - let device = MtpDevice::open_first().await?; - - println!("Connected to {} {}", - device.device_info().manufacturer, - device.device_info().model); - - // List storages (internal storage, SD card, etc.) - for storage in device.storages().await? { - println!("{}: {:.2} GB free", - storage.info().description, - storage.info().free_space_bytes as f64 / 1e9); - - // List files in root - for file in storage.list_objects(None).await? { - let icon = if file.is_folder() { "📁" } else { "📄" }; - println!(" {} {}", icon, file.filename); - } - } - - Ok(()) -} -``` - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -mtp-rs = "0.12" -``` - -You'll also need an async runtime. The library is runtime-agnostic, but [tokio](https://github.com/tokio-rs/tokio) is -the most common choice: - -```toml -[dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -``` - -### Platform notes - -#### Linux - -You may need udev rules to access USB devices without root. Create `/etc/udev/rules.d/99-mtp.rules`: - -``` -SUBSYSTEM=="usb", ATTR{idVendor}=="*", MODE="0666" -``` - -Then run `sudo udevadm control --reload-rules`. - -#### macOS - -It's a bit of a nightmare because macOS's built-in `ptpcamerad` daemon automatically claims MTP/PTP devices right on -connection, blocking other apps. This sucks because it it NOT `MTP`, just `PTP`, so Android phones, Kindles, etc. -won't be able to sync files through it, and at the same time, other apps (like potentially yours if you're looking at -this) will be unable to access the device. 🤯 - -One more potential offender is [Android File Transfer](https://www.android-file-transfer-mac.com/): If installed, it -spawns a process that also grabs devices. You must quit it before trying to connect to an MTP device using this (or, -honestly, any) library. - -**Workarounds:** - -1. **Kill loop**: Run this in Terminal while using your app: - ```bash - while true; do pkill -9 ptpcamerad 2>/dev/null; sleep 1; done - ``` - -2. **Disable `ptpcamerad`**: Persistent, but may break Photos.app: - - ```bash - sudo launchctl disable system/com.apple.ptpcamerad - ``` - -**Other tips for app developers:** - -- This library provides `Error::is_exclusive_access()`. Use this to detect this condition and guide users to apply - one of the workarounds above. -- Query IORegistry for `UsbExclusiveOwner` to show which process (pid, name) holds the device for even more helpful info -- App Store sandboxed apps cannot kill processes. If your app is such, then provide the command for users to run - manually. - If your app isn't in the App Store, then you're in a better position and may be able to use the workarounds, BUT - it's a bit murky territory with Apple. -- See [Cmdr](https://github.com/vdavid/cmdr) and [Commander One](https://mac.eltima.com/file-manager.html) for UX - inspiration on handling this gracefully. - -#### Windows - -Should work, and no dependencies needed, but we haven't tested it. - -## Examples - -These might come in handy: - -### Download a file - -```rust -let storage = & device.storages().await?[0]; - -// Find a file -let files = storage.list_objects(None).await?; -let photo = files.iter().find( | f| f.filename == "photo.jpg").unwrap(); - -// Download it -let data = storage.download(photo.handle).await?.collect().await?; -std::fs::write("photo.jpg", data) ?; -``` - -### Upload a file - -```rust -use mtp_rs::mtp::NewObjectInfo; -use bytes::Bytes; - -let content = std::fs::read("document.pdf") ?; -let info = NewObjectInfo::file("document.pdf", content.len() as u64); - -let stream = futures::stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(content))]); -let handle = storage.upload(None, info, Box::pin(stream)).await?; - -println!("Uploaded with handle {:?}", handle); -``` - -### Download with progress - -```rust -let mut download = storage.download_stream(file.handle).await?; -println!("Downloading {} bytes...", download.size()); - -while let Some(chunk) = download.next_chunk().await { -let bytes = chunk ?; -// Process bytes... -println ! ("{:.1}%", download.progress() * 100.0); -} -``` - -### Partial reads (byte ranges) - -Useful for previews, thumbnails, streaming media, or random access into large files: - -```rust -// First 1 MB of a file -let head = storage.download_partial(file.handle, 0, 1024 * 1024).await?; - -// Read from the middle -let middle = storage.download_partial(file.handle, 5_000_000, 100_000).await?; -``` - -For files larger than 4 GB, use the 64-bit variant (requires device support — most modern -Android devices advertise it): - -```rust -// Read 64 KB at offset 6 GB -let chunk = storage.download_partial_64(file.handle, 6 * 1024 * 1024 * 1024, 65536).await?; -``` - -### Listen for events - -`next_event()` awaits indefinitely, so wrap it in a timeout to allow checking for shutdown, etc.: - -```rust -use tokio::time::{timeout, Duration}; - -loop { - match timeout(Duration::from_millis(200), device.next_event()).await { - Ok(Ok(event)) => match event { - DeviceEvent::ObjectAdded { handle } => { - println!("New file: {:?}", handle); - } - DeviceEvent::StoreRemoved { storage_id } => { - println!("Storage unplugged: {:?}", storage_id); - } - _ => {} - }, - Ok(Err(Error::Disconnected)) => break, - Ok(Err(e)) => eprintln!("Error: {}", e), - Err(_) => continue, // Timeout — check for shutdown, etc. - } -} -``` - -## Virtual device (testing without hardware) - -The `virtual-device` feature lets you test MTP client code against a local filesystem directory instead of a real USB -device. Enable it in your `Cargo.toml`: - -```toml -[dev-dependencies] -mtp-rs = { version = "0.4", features = ["virtual-device"] } -``` - -### Direct usage - -```rust -use std::path::PathBuf; -use std::time::Duration; -use mtp_rs::{MtpDevice, VirtualDeviceConfig, VirtualStorageConfig}; - -let device = MtpDevice::builder() - .open_virtual(VirtualDeviceConfig { - manufacturer: "Google".into(), - model: "Virtual Pixel 9".into(), - serial: "virtual-001".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 64 * 1024 * 1024 * 1024, - backing_dir: PathBuf::from("/tmp/mtp-test"), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::from_millis(50), - watch_backing_dirs: true, - }) - .await?; -``` - -When `watch_backing_dirs` is `true`, the virtual device watches its backing directories for external changes (files -created or removed outside of MTP) and emits `ObjectAdded`/`ObjectRemoved` events, just like a real device would. -Set it to `false` in tests that don't need this for faster startup. - -### Discovery registry - -You can also register virtual devices so they appear in `MtpDevice::list_devices()` and can be opened with -`open_by_location()` or `open_by_serial()`: - -```rust -use mtp_rs::{register_virtual_device, unregister_virtual_device}; - -let info = register_virtual_device(&config); - -// Now discoverable -let device = MtpDevice::builder().open_by_serial("virtual-001").await?; - -// Clean up when done -unregister_virtual_device(info.location_id); -``` - -## API overview - -The library has two layers: - -### High-level API (`mtp::`) - -This is what most people want. Friendly types, automatic session management, streaming. - -- `MtpDevice` - Connect to devices, get info, list storages -- `Storage` - File operations (list, download, upload, delete, move, copy) -- `DownloadStream` - Streaming downloads with progress -- `DeviceEvent` - Events from the device - -### Low-level API (`ptp::`) - -For when you need raw protocol access (for cameras or maybe debugging). - -- `PtpDevice` - Raw device connection -- `PtpSession` - Manual session control, raw operations -- `OperationCode`, `ResponseCode` - Protocol constants -- Container types for building/parsing protocol messages - -With this, you can copy stuff to/from cameras, but there are no other features like reading the battery level, -trigger capture, read supported formats/sizes, etc. This is intentional, didn't want to bloat the library with -camera-specific code because this is mainly for MTP and file transfer. - -## Runtime compatibility - -The library uses `futures` traits and is runtime-agnostic. It's tested with tokio but should work with async-std or any -other runtime. - -We use `nusb` for USB access, which is also runtime-agnostic. - -## Known limitations - -| Limitation | Details | -|---------------------------|----------------------------------------------------| -| Files >4GB (size field) | `ObjectInfo::size` is u32 and caps at 4 GB. For byte-range reads beyond 4 GB, use `download_partial_64()` (tested end-to-end on Pixel 9 Pro XL with an 8 GB file). | -| Filename length | Max 254 characters | -| Non-empty folder delete | Fails; delete contents first | -| One connection per device | Can't open the same device twice | -| Upload cancellation | Partial files may remain on device | -| Recursive listing speed | Manual traversal is slower (~1 request per folder) | - -## Android weirdnesses - -Android's MTP implementation has some quirks that this library handles automatically: - -- **Behavior:** Recursive listing broken - - **What happens:** `ObjectHandle::ALL` returns incomplete results (folders only, no files) - - **How we handle it:** Auto-detected; uses manual folder traversal instead. Although, note that it takes a lot more - time! Like, if the device supported this, it'd be pretty fast, while with the workaround, in the tests it took - 9 minutes to list ~20k files in ~2k folders. -- **Behavior:** Can't create in root - - **What happens:** Creating files/folders in storage root fails with `InvalidObjectHandle` - - **How we handle it:** Use a subfolder like `Download/` as the parent -- **Behavior:** Large responses span transfers - - **What happens:** Data >64KB comes in multiple USB transfers - - **How we handle it:** Automatically reassembled before parsing -- **Behavior:** Composite USB devices - - **What happens:** Most phones report as USB class 0 (composite) - - **How we handle it:** We inspect interfaces to find MTP - -The library detects Android devices via the `"android.com"` vendor extension and applies appropriate handling -automatically. -You generally don't need to worry about these details. - -**Tip**: When uploading files, use a known folder like `Download/` rather than the storage root: - -```rust -// Find the Download folder -let objects = storage.list_objects(None).await?; -let download = objects.iter().find( | o| o.filename == "Download").unwrap(); - -// Upload to Download folder (not root) -storage.upload(Some(download.handle), file_info, data).await?; -``` - -## Tested devices - -"Full support" really means "Full support, except for general Android quirks listed above". - -| Device | Android | Notes | -|--------------------------------------------------|---------|-----------------| -| Google Pixel 9 Pro XL | 15 | Full support | -| Samsung Galaxy S23 Ultra (SM-S918B) | 14 | No root listing | -| [Amazon Kindle Paperwhite 12th Generation (2024)](https://github.com/vdavid/mtp-rs/pull/2#issuecomment-4264713119) | - | Full support | -| [Fairphone 5](https://github.com/vdavid/mtp-rs/issues/6#issuecomment-4234861708) (e/OS 3.0.4, LineageOS-derived) | 13 | Full support | -| [Garmin Forerunner 955](https://github.com/vdavid/mtp-rs/pull/10) | - | Works for app use; integration suite has one failing test under investigation | - -**Samsung quirk**: Samsung devices return `InvalidObjectHandle` when listing the root folder with handle 0. -The library automatically detects this and falls back to recursive listing with filtering. This is transparent to users. - -We welcome reports of other tested devices! Please open an issue or PR with your device model, Android version, -and any issues encountered. - -## Benchmarks - -mtp-rs is faster than libmtp across every operation we tested, and the gap widens with file size. On a Google Pixel 9 -Pro XL (USB, 5 warmup + 10 measured runs per scenario): - -| Operation | Size | mtp-rs | libmtp | Speedup | -|------------|--------|---------|---------|-----------| -| download | 1 MB | 33.9ms | 45.3ms | **1.34x** | -| download | 10 MB | 258.3ms | 391.1ms | **1.51x** | -| download | 100 MB | 2.447s | 9.897s | **4.04x** | -| upload | 1 MB | 76.1ms | 115.0ms | **1.51x** | -| upload | 10 MB | 326.9ms | 345.1ms | **1.06x** | -| upload | 100 MB | 2.388s | 2.796s | **1.17x** | -| list_files | - | 15.5ms | 24.9ms | **1.61x** | - -Beyond raw speed, mtp-rs is far more predictable. At 100 MB downloads, libmtp's individual runs ranged from 3.7s to -18.2s (std dev 4.6s — that's 47% of its median). mtp-rs stayed within a 15ms band (std dev 4.7ms — 0.2% of its median). -In practice this means a 100 MB transfer with mtp-rs reliably takes ~2.4s, while with libmtp it could take anywhere from -4s to 18s. - -For large-file throughput, a separate test uploaded an 8.06 GB file to a Pixel 9 Pro XL over USB 3.2 in 98.5s — -sustained **83.8 MB/s**. Partial reads at offsets 2 GB, 4.1 GB, and near-EOF all returned correct bytes, confirming -`GetPartialObject64` works end-to-end on Android. - -The benchmark tool is included in the repo. [Run it yourself](benchmarks/mtp-rs-vs-libmtp/) with -`cargo run -p mtp-bench -- --warmup 5 --runs 10`. - -## Comparison with other libraries - -### vs libmtp / libmtp-rs - -[libmtp](https://github.com/libmtp/libmtp/) is 20+ years old, battle-tested, and very comprehensive. -[libmtp-rs](https://github.com/quebin31/libmtp-rs) provides a Rust interface to it. But: - -- `libmtp` is a C library with all the FFI pain that entails -- It has a massive device quirks database for hardware from 2006 -- The API is synchronous and callback-heavy -- It pulls in `libusb`, `libudev`, and other system dependencies - -In contrast, `mtp-rs` targets modern Android devices that all behave the same way. If you need to support a weird -MP3 player from 2008, use libmtp. If you're building a modern Android sync tool, mtp-rs is a better fit. - -### vs existing Rust PTP crates - -[ptp](https://crates.io/crates/ptp) and [libptp](https://crates.io/crates/libptp) both use -[libusb](https://github.com/libusb/libusb) v0.3 for USB access, which is a C dependency. - -`mtp-rs` uses [nusb](https://crates.io/crates/nusb) instead, which is pure Rust. - -Note that `libptp` is much more mature, though! - -### vs winmtp - -[winmtp](https://crates.io/crates/winmtp) wraps the Windows COM API, which is Windows only. `mtp-rs` works on Linux, -macOS, and Windows. - -## Implementation notes - -- I used Opus 4.5 extensively for this implementation. I know it's controversial these days, but the bottom line to me - is that the implementation WORKS, it has a bunch of integration tests which pass, and hey, I can use it to copy data - to/from my phone and other phones and I can display async progress and I don't need to rely on C libraries. So no - hate, - please. If you dislike or distrust AI-gen code, use the alternatives listed above (if you can live with the libmtp - dependency), handcraft your own Rust implementation, or fork this repo and add your human thing and use it. - PRs are also welcome. -- For the protocol spec, I tried to use - usb.org's [Media Transfer Protocol v.1.1 Spec](https://www.usb.org/document-library/media-transfer-protocol-v11-spec-and-mtp-v11-adopters-agreement), - but it was a pain to get AI agents to work from it, so I've converted it to Markdown. You can find it - here: https://github.com/vdavid/mtp-v1_1-spec-md - I've also shared it back with the USB.org team, so they might link it on the official page. - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -## License - -MIT OR Apache-2.0, at your option. diff --git a/vendor/mtp-rs/TP7-PATCH.md b/vendor/mtp-rs/TP7-PATCH.md deleted file mode 100644 index d309222..0000000 --- a/vendor/mtp-rs/TP7-PATCH.md +++ /dev/null @@ -1,12 +0,0 @@ -# TP-7 session-reuse patch - -This is `mtp-rs` 0.13.3 with a small opt-in session policy used by the TP-7 -CLI. The patch adds: - -- `PtpSession::open_reusing_existing` -- `MtpDeviceBuilder::session_id` -- `MtpDeviceBuilder::reuse_existing_session` - -Default `mtp-rs` behavior is unchanged. The TP-7 opts into reuse because its -firmware treats `CloseSession` as a request to leave MTP mode. FieldKit uses a -stable session ID and reuses the device-side session across app processes. diff --git a/vendor/mtp-rs/src/error.rs b/vendor/mtp-rs/src/error.rs deleted file mode 100644 index bcd7a9a..0000000 --- a/vendor/mtp-rs/src/error.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Error types for mtp-rs. - -use thiserror::Error; - -/// The main error type for mtp-rs operations. -#[derive(Debug, Error)] -pub enum Error { - /// USB communication error - #[error("USB error: {0}")] - Usb(#[from] nusb::Error), - - /// Protocol-level error from device - #[error("Protocol error: {code:?} during {operation:?}")] - Protocol { - /// The response code returned by the device. - code: crate::ptp::ResponseCode, - /// The operation that triggered the error. - operation: crate::ptp::OperationCode, - }, - - /// Invalid data received from device - #[error("Invalid data: {message}")] - InvalidData { - /// Description of what was invalid. - message: String, - }, - - /// I/O error - #[error("I/O error: {0}")] - Io(std::io::Error), - - /// Operation timed out - #[error("Operation timed out")] - Timeout, - - /// Device was disconnected - #[error("Device disconnected")] - Disconnected, - - /// Session not open - #[error("Session not open")] - SessionNotOpen, - - /// No device found - #[error("No MTP device found")] - NoDevice, - - /// Operation cancelled - #[error("Operation cancelled")] - Cancelled, -} - -impl Error { - /// Create an invalid data error with a message. - #[must_use] - pub fn invalid_data(message: impl Into) -> Self { - Error::InvalidData { - message: message.into(), - } - } - - /// Check if this is a retryable error. - /// - /// Retryable errors are transient and the operation may succeed if retried: - /// - `DeviceBusy`: Device is temporarily busy - /// - `Timeout`: Operation timed out but device may still be responsive - #[must_use] - pub fn is_retryable(&self) -> bool { - matches!( - self, - Error::Protocol { - code: crate::ptp::ResponseCode::DeviceBusy, - .. - } | Error::Timeout - ) - } - - /// Get the response code if this is a protocol error. - #[must_use] - pub fn response_code(&self) -> Option { - match self { - Error::Protocol { code, .. } => Some(*code), - _ => None, - } - } - - /// Check if this error indicates another process has exclusive access to the device. - /// - /// This typically happens on macOS when `ptpcamerad` or another application - /// has already claimed the USB interface. Applications can use this to provide - /// platform-specific guidance to users. - /// - /// # Example - /// - /// ```ignore - /// match device.open().await { - /// Err(e) if e.is_exclusive_access() => { - /// // On macOS, likely ptpcamerad interference - /// // App can query IORegistry for UsbExclusiveOwner to get details - /// show_exclusive_access_help(); - /// } - /// Err(e) => handle_other_error(e), - /// Ok(dev) => use_device(dev), - /// } - /// ``` - #[must_use] - pub fn is_exclusive_access(&self) -> bool { - match self { - Error::Usb(io_err) => { - let msg = io_err.to_string().to_lowercase(); - // macOS: "could not be opened for exclusive access" - // Linux: typically EBUSY, but message varies - // Windows: "access denied" or similar - msg.contains("exclusive access") - || msg.contains("device or resource busy") - || (msg.contains("access") && msg.contains("denied")) - } - Error::Io(io_err) => { - let msg = io_err.to_string().to_lowercase(); - msg.contains("exclusive access") - || msg.contains("device or resource busy") - || (msg.contains("access") && msg.contains("denied")) - } - _ => false, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Error as IoError, ErrorKind}; - - #[test] - fn test_is_exclusive_access_macos_message() { - // macOS nusb error message (tested via Io variant; same logic as Usb variant) - let io_err = IoError::other("could not be opened for exclusive access"); - let err = Error::Io(io_err); - assert!(err.is_exclusive_access()); - } - - #[test] - fn test_is_exclusive_access_linux_busy() { - // Linux EBUSY style message (tested via Io variant; same logic as Usb variant) - let io_err = IoError::other("Device or resource busy"); - let err = Error::Io(io_err); - assert!(err.is_exclusive_access()); - } - - #[test] - fn test_is_exclusive_access_windows_denied() { - // Windows access denied style message (tested via Io variant; same logic as Usb variant) - let io_err = IoError::new(ErrorKind::PermissionDenied, "Access is denied"); - let err = Error::Io(io_err); - assert!(err.is_exclusive_access()); - } - - #[test] - fn test_is_exclusive_access_io_error() { - // Also works for Io variant - let io_err = IoError::other("could not be opened for exclusive access"); - let err = Error::Io(io_err); - assert!(err.is_exclusive_access()); - } - - #[test] - fn test_is_exclusive_access_false_for_other_errors() { - assert!(!Error::Timeout.is_exclusive_access()); - assert!(!Error::Disconnected.is_exclusive_access()); - assert!(!Error::NoDevice.is_exclusive_access()); - assert!(!Error::invalid_data("some error").is_exclusive_access()); - - let io_err = IoError::new(ErrorKind::NotFound, "device not found"); - assert!(!Error::Io(io_err).is_exclusive_access()); - } -} diff --git a/vendor/mtp-rs/src/lib.rs b/vendor/mtp-rs/src/lib.rs deleted file mode 100644 index 1258cc3..0000000 --- a/vendor/mtp-rs/src/lib.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! # mtp-rs -//! -//! A pure-Rust MTP (Media Transfer Protocol) library targeting modern Android devices. -//! -//! ## Features -//! -//! - **Runtime agnostic**: Uses `futures` traits, works with any async runtime -//! - **Two-level API**: High-level `mtp::` for media devices, low-level `ptp::` for cameras -//! - **Streaming**: Memory-efficient streaming downloads with progress tracking -//! - **Type safe**: Newtype wrappers prevent mixing up IDs -//! -//! ## Quick start -//! -//! ```rust,no_run -//! use mtp_rs::mtp::MtpDevice; -//! -//! # async fn example() -> Result<(), mtp_rs::Error> { -//! // Open the first MTP device -//! let device = MtpDevice::open_first().await?; -//! -//! println!("Connected to: {} {}", -//! device.device_info().manufacturer, -//! device.device_info().model); -//! -//! // Get storages -//! for storage in device.storages().await? { -//! println!("Storage: {} ({} free)", -//! storage.info().description, -//! storage.info().free_space_bytes); -//! -//! // List root folder -//! for obj in storage.list_objects(None).await? { -//! let kind = if obj.is_folder() { "DIR " } else { "FILE" }; -//! println!(" {} {} ({} bytes)", kind, obj.filename, obj.size); -//! } -//! } -//! # Ok(()) -//! # } -//! ``` - -pub mod error; -pub mod mtp; -pub mod ptp; -pub mod transport; - -pub use error::Error; - -// Re-export core types for convenience -pub use ptp::{ - receive_stream_to_stream, DateTime, EventCode, ObjectFormatCode, ObjectHandle, OperationCode, - ReceiveStream, ResponseCode, SessionId, StorageId, TransactionId, -}; - -// Re-export high-level MTP types -pub use mtp::{ - DeviceEvent, FileDownload, MtpDevice, MtpDeviceBuilder, NewObjectInfo, ObjectListing, Progress, - Storage, DEFAULT_CANCEL_TIMEOUT, -}; - -// Re-export virtual device types when the feature is enabled -#[cfg(feature = "virtual-device")] -pub use transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; -#[cfg(feature = "virtual-device")] -pub use transport::virtual_device::registry::{ - pause_watcher, register_virtual_device, rescan_virtual_device, unregister_virtual_device, - WatcherGuard, -}; -#[cfg(feature = "virtual-device")] -pub use transport::virtual_device::RescanSummary; diff --git a/vendor/mtp-rs/src/mtp/device.rs b/vendor/mtp-rs/src/mtp/device.rs deleted file mode 100644 index b90bcec..0000000 --- a/vendor/mtp-rs/src/mtp/device.rs +++ /dev/null @@ -1,728 +0,0 @@ -//! MtpDevice - the main entry point for MTP operations. - -use crate::mtp::{DeviceEvent, Storage}; -use crate::ptp::{DeviceInfo, ObjectHandle, PtpSession, StorageId}; -use crate::transport::{NusbTransport, Transport}; -use crate::Error; -use std::sync::Arc; -use std::time::Duration; - -/// Internal shared state for MtpDevice. -pub(crate) struct MtpDeviceInner { - pub(crate) session: Arc, - pub(crate) device_info: DeviceInfo, -} - -impl MtpDeviceInner { - /// Check if the device is an Android device. - /// - /// Detected by looking for "android.com" in the vendor extension descriptor. - /// Android devices have known MTP quirks (e.g., ObjectHandle::ALL doesn't work - /// for recursive listing). - #[must_use] - pub fn is_android(&self) -> bool { - self.device_info - .vendor_extension_desc - .to_lowercase() - .contains("android.com") - } -} - -/// An MTP device connection. -/// -/// This is the main entry point for interacting with MTP devices. -/// Use `MtpDevice::open_first()` to connect to the first available device, -/// or `MtpDevice::builder()` for more control. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::mtp::MtpDevice; -/// -/// # async fn example() -> Result<(), mtp_rs::Error> { -/// // Open the first MTP device -/// let device = MtpDevice::open_first().await?; -/// -/// println!("Connected to: {} {}", -/// device.device_info().manufacturer, -/// device.device_info().model); -/// -/// // Get storages -/// for storage in device.storages().await? { -/// println!("Storage: {} ({} free)", -/// storage.info().description, -/// storage.info().free_space_bytes); -/// } -/// # Ok(()) -/// # } -/// ``` -#[derive(Clone)] -pub struct MtpDevice { - inner: Arc, -} - -impl MtpDevice { - /// Create a builder for configuring device options. - pub fn builder() -> MtpDeviceBuilder { - MtpDeviceBuilder::new() - } - - /// Open the first available MTP device with default settings. - pub async fn open_first() -> Result { - Self::builder().open_first().await - } - - /// Open a device at a specific USB location (port) with default settings. - /// - /// Use `list_devices()` to get available location IDs. - pub async fn open_by_location(location_id: u64) -> Result { - Self::builder().open_by_location(location_id).await - } - - /// Open a device by its serial number with default settings. - /// - /// This identifies a specific physical device regardless of which USB port - /// it's connected to. - pub async fn open_by_serial(serial: &str) -> Result { - Self::builder().open_by_serial(serial).await - } - - /// List all available MTP devices without opening them. - pub fn list_devices() -> Result, Error> { - Self::list_devices_with_known(&[]) - } - - /// List all available MTP devices, including additional devices identified - /// by the given VID/PID pairs. - /// - /// Devices matching the provided VID/PID pairs are included in the results - /// even if their USB descriptors don't match standard MTP class codes. This - /// is useful for legacy or otherwise unusual devices with non-standard USB - /// descriptors that still speak MTP. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// - /// let devices = MtpDevice::list_devices_with_known(&[ - /// (0x045E, 0x0710), // custom VID/PID - /// ])?; - /// for d in &devices { - /// println!("{:04x}:{:04x} {}", d.vendor_id, d.product_id, - /// d.product.as_deref().unwrap_or("unknown")); - /// } - /// # Ok::<(), mtp_rs::Error>(()) - /// ``` - pub fn list_devices_with_known(known: &[(u16, u16)]) -> Result, Error> { - let devices = NusbTransport::list_mtp_devices_with_known(known)?; - #[allow(unused_mut)] - let mut result: Vec = devices - .into_iter() - .map(|d| MtpDeviceInfo { - vendor_id: d.vendor_id, - product_id: d.product_id, - manufacturer: d.manufacturer, - product: d.product, - serial_number: d.serial_number, - location_id: d.location_id, - }) - .collect(); - - #[cfg(feature = "virtual-device")] - result.extend(crate::transport::virtual_device::registry::list_virtual_devices()); - - Ok(result) - } - - /// Get device information. - #[must_use] - pub fn device_info(&self) -> &DeviceInfo { - &self.inner.device_info - } - - /// Get a reference to the underlying [`PtpSession`]. - /// - /// Provides direct access to low-level PTP operations - /// ([`execute`](PtpSession::execute), - /// [`execute_with_send`](PtpSession::execute_with_send), - /// [`execute_with_receive`](PtpSession::execute_with_receive)) - /// and per-device knobs like - /// [`set_split_header_data`](PtpSession::set_split_header_data). - #[must_use] - pub fn session(&self) -> &PtpSession { - &self.inner.session - } - - /// Check if the device supports renaming objects. - /// - /// This checks for support of the SetObjectPropValue operation (0x9804), - /// which is required to rename files and folders via the ObjectFileName property. - /// - /// # Returns - /// - /// Returns true if the device advertises SetObjectPropValue support. - #[must_use] - pub fn supports_rename(&self) -> bool { - self.inner.device_info.supports_rename() - } - - /// Get all storages on the device. - pub async fn storages(&self) -> Result, Error> { - let ids = self.inner.session.get_storage_ids().await?; - let mut storages = Vec::with_capacity(ids.len()); - for id in ids { - let info = self.inner.session.get_storage_info(id).await?; - storages.push(Storage::new(self.inner.clone(), id, info)); - } - Ok(storages) - } - - /// Get a specific storage by ID. - pub async fn storage(&self, id: StorageId) -> Result { - let info = self.inner.session.get_storage_info(id).await?; - Ok(Storage::new(self.inner.clone(), id, info)) - } - - /// Get object handles in a storage. - /// - /// # Arguments - /// - /// * `storage_id` - Storage to search, or `StorageId::ALL` for all storages - /// * `parent` - Parent folder handle, or `None` for root level only, - /// or `Some(ObjectHandle::ALL)` for recursive listing - pub async fn get_object_handles( - &self, - storage_id: StorageId, - parent: Option, - ) -> Result, Error> { - self.inner - .session - .get_object_handles(storage_id, None, parent) - .await - } - - /// Receive the next event from the device. - /// - /// This method awaits **indefinitely** on the USB interrupt endpoint until an - /// event arrives or the device disconnects. Always wrap this in - /// `tokio::time::timeout` (or equivalent) so you can check for shutdown. - /// - /// # Concurrency - /// - /// Event reading uses the USB interrupt endpoint, which is independent from the - /// bulk endpoints used by file operations (`list_objects`, `get_object`, etc.). - /// It is safe to call `next_event()` concurrently with other `MtpDevice` methods. - /// - /// If you wrap `MtpDevice` in a shared lock (for example, `Arc>`), - /// do **not** hold that lock while awaiting `next_event()` — it will block all file - /// operations for the duration of the wait. Instead, clone the `MtpDevice` (it is - /// cheaply cloneable via `Arc` internally) and call `next_event()` on the clone - /// without holding the lock. - /// - /// # Returns - /// - /// - `Ok(event)` - An event was received from the device - /// - `Err(Error::Disconnected)` - Device was disconnected - /// - `Err(_)` - Other communication error - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::{MtpDevice, DeviceEvent}; - /// use mtp_rs::Error; - /// use tokio::time::{timeout, Duration}; - /// - /// # async fn example() -> Result<(), Error> { - /// # let device = MtpDevice::open_first().await?; - /// loop { - /// match timeout(Duration::from_millis(200), device.next_event()).await { - /// Ok(Ok(event)) => { - /// match event { - /// DeviceEvent::ObjectAdded { handle } => { - /// println!("New object: {:?}", handle); - /// } - /// DeviceEvent::StoreRemoved { storage_id } => { - /// println!("Storage removed: {:?}", storage_id); - /// } - /// _ => {} - /// } - /// } - /// Ok(Err(Error::Disconnected)) => break, - /// Ok(Err(e)) => { - /// eprintln!("Error: {}", e); - /// break; - /// } - /// Err(_elapsed) => continue, // Timeout, check for shutdown etc. - /// } - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn next_event(&self) -> Result { - match self.inner.session.poll_event().await? { - Some(container) => Ok(DeviceEvent::from_container(&container)), - None => Err(Error::Timeout), - } - } - - /// Close the connection (also happens on drop). - pub async fn close(self) -> Result<(), Error> { - // Try to close gracefully, but Arc might have multiple references - if let Ok(inner) = Arc::try_unwrap(self.inner) { - if let Ok(session) = Arc::try_unwrap(inner.session) { - session.close().await?; - } - } - Ok(()) - } -} - -/// Information about an MTP device (without opening it). -/// -/// This struct provides device identification at multiple levels: -/// -/// - **Device identity** (`vendor_id`, `product_id`, `serial_number`): Identifies -/// a specific physical device. Use this to recognize "John's phone" regardless -/// of which USB port it's plugged into. -/// -/// - **Port identity** (`location_id`): Identifies the physical USB port/location. -/// Use this when you care about "the device on port 3" rather than which -/// specific device it is. Stable across reconnections to the same port. -/// -/// - **Display info** (`manufacturer`, `product`): Human-readable strings for -/// showing device info to users. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::mtp::MtpDevice; -/// -/// let devices = MtpDevice::list_devices()?; -/// for dev in &devices { -/// println!("{} {} (serial: {:?})", -/// dev.manufacturer.as_deref().unwrap_or("Unknown"), -/// dev.product.as_deref().unwrap_or("Unknown"), -/// dev.serial_number); -/// } -/// -/// // Save location_id to remember "the device on this port" -/// // Save serial_number to remember "this specific phone" -/// # Ok::<(), mtp_rs::Error>(()) -/// ``` -#[derive(Debug, Clone)] -pub struct MtpDeviceInfo { - /// USB vendor ID (assigned by USB-IF to each company). - /// - /// Examples: Google = `0x18d1`, Samsung = `0x04e8`, Apple = `0x05ac` - pub vendor_id: u16, - - /// USB product ID (assigned by vendor to each product model). - /// - /// Note: The same device may report different product IDs depending on - /// its USB mode (MTP, ADB, charging-only, etc.). - pub product_id: u16, - - /// Manufacturer name from USB descriptor. - /// - /// Examples: `"Google"`, `"Samsung"`, `"Apple Inc."` - /// - /// `None` if the device doesn't report a manufacturer string. - pub manufacturer: Option, - - /// Product name from USB descriptor. - /// - /// Examples: `"Pixel 9 Pro XL"`, `"Galaxy S24"` - /// - /// `None` if the device doesn't report a product string. - pub product: Option, - - /// Serial number uniquely identifying this specific device. - /// - /// Combined with `vendor_id` and `product_id`, this globally identifies - /// a single physical device. Survives reconnection to different ports. - /// - /// `None` if the device doesn't report a serial number. - pub serial_number: Option, - - /// Physical USB location identifier. - /// - /// Identifies the USB port/path where the device is connected. Stable - /// across reconnections to the same physical port, but changes if the - /// device is moved to a different port. - /// - /// Derived cross-platform from the USB bus ID and port chain (topology). - pub location_id: u64, -} - -impl MtpDeviceInfo { - /// Format the device info for display. - #[must_use] - pub fn display(&self) -> String { - let manufacturer = self.manufacturer.as_deref().unwrap_or("Unknown"); - let product = self.product.as_deref().unwrap_or("Unknown"); - match &self.serial_number { - Some(serial) => format!( - "{} {} (serial: {}, location: {:08x})", - manufacturer, product, serial, self.location_id - ), - None => format!( - "{} {} (location: {:08x})", - manufacturer, product, self.location_id - ), - } - } -} - -/// Builder for MtpDevice configuration. -pub struct MtpDeviceBuilder { - timeout: Duration, - known_devices: Vec<(u16, u16)>, - session_id: u32, - reuse_existing_session: bool, -} - -impl MtpDeviceBuilder { - #[must_use] - pub fn new() -> Self { - Self { - timeout: NusbTransport::DEFAULT_TIMEOUT, - known_devices: Vec::new(), - session_id: 1, - reuse_existing_session: false, - } - } - - /// Set bulk transfer timeout (default: 30 seconds). - /// - /// This timeout applies to file transfers, command responses, and event polling. - /// Use longer timeouts for large file operations. - #[must_use] - pub fn timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } - - /// Include additional devices identified by VID/PID pairs in the open-time - /// device scan. - /// - /// By default, the `open_first` / `open_by_serial` / `open_by_location` - /// convenience methods only consider devices whose USB descriptors match - /// standard MTP class codes. Pass extra VID/PID pairs here to also accept - /// legacy or otherwise unusual devices that speak MTP despite reporting - /// non-standard descriptors. - /// - /// This is the open-side counterpart to [`MtpDevice::list_devices_with_known`]: - /// pair them with the same list to enumerate and open the same set of devices. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// let known = &[(0x045E, 0x0710)]; - /// - /// // Enumerate with the extra VID/PID... - /// let devices = MtpDevice::list_devices_with_known(known)?; - /// let info = devices.into_iter().next().ok_or(mtp_rs::Error::NoDevice)?; - /// - /// // ...and open with the same list so the builder can find the device. - /// let device = MtpDevice::builder() - /// .known_devices(known) - /// .open_by_location(info.location_id) - /// .await?; - /// - /// // Apply any per-device knobs after opening. - /// device.session().set_split_header_data(true); - /// # Ok(()) - /// # } - /// ``` - #[must_use] - pub fn known_devices(mut self, known: &[(u16, u16)]) -> Self { - self.known_devices = known.to_vec(); - self - } - - /// Set the PTP session identifier used by this connection. - #[must_use] - pub fn session_id(mut self, session_id: u32) -> Self { - self.session_id = session_id; - self - } - - /// Reuse a session that the device reports as already open. - #[must_use] - pub fn reuse_existing_session(mut self, reuse: bool) -> Self { - self.reuse_existing_session = reuse; - self - } - - /// Open the first available device. - pub async fn open_first(self) -> Result { - let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; - let device_info = devices.into_iter().next().ok_or(Error::NoDevice)?; - let device = device_info.open().map_err(Error::Usb)?; - self.open_nusb_device(device).await - } - - /// Open a device at a specific USB location (port). - /// - /// Use `MtpDevice::list_devices()` to get available location IDs. - /// Also checks the virtual device registry when the `virtual-device` feature is enabled. - pub async fn open_by_location(self, location_id: u64) -> Result { - #[cfg(feature = "virtual-device")] - if let Some(config) = - crate::transport::virtual_device::registry::find_virtual_config_by_location(location_id) - { - return self.open_virtual(config).await; - } - - let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; - let device_info = devices - .into_iter() - .find(|d| d.location_id == location_id) - .ok_or(Error::NoDevice)?; - let device = device_info.open().map_err(Error::Usb)?; - self.open_nusb_device(device).await - } - - /// Open a device by its serial number. - /// - /// This identifies a specific physical device regardless of which USB port - /// it's connected to. Also checks the virtual device registry when the - /// `virtual-device` feature is enabled. - pub async fn open_by_serial(self, serial: &str) -> Result { - #[cfg(feature = "virtual-device")] - if let Some(config) = - crate::transport::virtual_device::registry::find_virtual_config_by_serial(serial) - { - return self.open_virtual(config).await; - } - - let devices = NusbTransport::list_mtp_devices_with_known(&self.known_devices)?; - let device_info = devices - .into_iter() - .find(|d| d.serial_number.as_deref() == Some(serial)) - .ok_or(Error::NoDevice)?; - let device = device_info.open().map_err(Error::Usb)?; - self.open_nusb_device(device).await - } - - /// Open an already-acquired [`nusb::Device`] as an MTP device. - /// - /// This is an escape hatch for consumers who already hold an `nusb::Device` - /// (e.g. from a custom enumeration or hotplug watcher). For most callers, - /// prefer [`known_devices`](Self::known_devices) combined with - /// `open_by_serial` / `open_by_location`. - /// - /// The interface scan is permissive: strict MTP-class match first, then - /// fallback to any interface with the MTP endpoint layout. Per-device knobs - /// like [`PtpSession::set_split_header_data`] can be applied after opening - /// via [`MtpDevice::session()`]. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// use nusb::MaybeFuture; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// let nusb_device = nusb::list_devices() - /// .wait()? - /// .find(|d: &nusb::DeviceInfo| { - /// d.vendor_id() == 0x045E && d.product_id() == 0x0710 - /// }) - /// .ok_or(mtp_rs::Error::NoDevice)? - /// .open() - /// .wait()?; - /// - /// let device = MtpDevice::builder() - /// .open_nusb_device(nusb_device) - /// .await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn open_nusb_device(self, device: nusb::Device) -> Result { - let transport = NusbTransport::open_with_timeout(device, self.timeout).await?; - let transport: Arc = Arc::new(transport); - - let session = if self.reuse_existing_session { - PtpSession::open_reusing_existing(transport.clone(), self.session_id).await? - } else { - PtpSession::open(transport.clone(), self.session_id).await? - }; - let session = Arc::new(session); - - // Get device info - let device_info = session.get_device_info().await?; - - // Quirk for Garmin devices - if device_info.manufacturer == "Garmin" { - session.set_split_header_data(true); - } - - let inner = Arc::new(MtpDeviceInner { - session, - device_info, - }); - - Ok(MtpDevice { inner }) - } - - /// Open a virtual device backed by local filesystem directories. - /// - /// This creates a virtual MTP device that speaks the full binary protocol but - /// operates against local directories instead of USB. Use this for testing MTP - /// client code without real hardware. - /// - /// # Example - /// - /// ```rust,no_run - /// use std::path::PathBuf; - /// use std::time::Duration; - /// use mtp_rs::MtpDevice; - /// use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// let device = MtpDevice::builder() - /// .open_virtual(VirtualDeviceConfig { - /// manufacturer: "Google".into(), - /// model: "Virtual Pixel 9".into(), - /// serial: "virtual-001".into(), - /// storages: vec![VirtualStorageConfig { - /// description: "Internal Storage".into(), - /// capacity: 64 * 1024 * 1024 * 1024, - /// backing_dir: PathBuf::from("/tmp/mtp-test"), - /// read_only: false, - /// }], - /// supports_rename: true, - /// event_poll_interval: Duration::from_millis(50), - /// watch_backing_dirs: true, - /// }) - /// .await?; - /// # Ok(()) - /// # } - /// ``` - #[cfg(feature = "virtual-device")] - pub async fn open_virtual( - self, - config: crate::transport::virtual_device::config::VirtualDeviceConfig, - ) -> Result { - if config.storages.is_empty() { - return Err(Error::invalid_data( - "VirtualDeviceConfig requires at least one storage", - )); - } - - let transport = crate::transport::virtual_device::VirtualTransport::new(config); - let transport: Arc = Arc::new(transport); - - // Open session (use session ID 1) - let session = Arc::new(PtpSession::open(transport.clone(), 1).await?); - - // Get device info - let device_info = session.get_device_info().await?; - - let inner = Arc::new(MtpDeviceInner { - session, - device_info, - }); - - Ok(MtpDevice { inner }) - } -} - -impl Default for MtpDeviceBuilder { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn list_devices_returns_ok() { - assert!(MtpDevice::list_devices().is_ok()); - } - - #[test] - fn builder_timeout() { - // Default value - let builder = MtpDeviceBuilder::new(); - assert_eq!(builder.timeout, NusbTransport::DEFAULT_TIMEOUT); - - // Custom value - let custom = MtpDeviceBuilder::new().timeout(Duration::from_secs(45)); - assert_eq!(custom.timeout, Duration::from_secs(45)); - } - - #[test] - fn device_info_display() { - let with_serial = MtpDeviceInfo { - vendor_id: 0x04e8, - product_id: 0x6860, - manufacturer: Some("Samsung".to_string()), - product: Some("Galaxy S24".to_string()), - serial_number: Some("ABC123".to_string()), - location_id: 0x00200000, - }; - let display = with_serial.display(); - assert!(display.contains("Samsung") && display.contains("Galaxy S24")); - assert!(display.contains("ABC123") && display.contains("00200000")); - - // Without serial - let no_serial = MtpDeviceInfo { - serial_number: None, - ..with_serial.clone() - }; - assert!(!no_serial.display().contains("serial:")); - - // Unknown manufacturer - let unknown = MtpDeviceInfo { - manufacturer: None, - product: None, - ..with_serial - }; - assert!(unknown.display().contains("Unknown")); - } - - #[cfg(feature = "virtual-device")] - #[tokio::test] - async fn open_virtual_empty_storages_rejected() { - use crate::transport::virtual_device::config::VirtualDeviceConfig; - - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Empty".into(), - serial: "empty-001".into(), - storages: vec![], - supports_rename: false, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - }; - - let result = MtpDevice::builder().open_virtual(config).await; - match result { - Err(err) => assert!( - err.to_string().contains("at least one storage"), - "unexpected error: {}", - err - ), - Ok(_) => panic!("expected error for empty storages"), - } - } - - #[tokio::test] - #[ignore] // Requires real MTP device - async fn real_device_operations() { - let device = MtpDevice::open_first().await.unwrap(); - println!("Connected to: {}", device.device_info().model); - for storage in device.storages().await.unwrap() { - println!("Storage: {}", storage.info().description); - } - device.close().await.unwrap(); - } -} diff --git a/vendor/mtp-rs/src/mtp/event.rs b/vendor/mtp-rs/src/mtp/event.rs deleted file mode 100644 index 7cbdba9..0000000 --- a/vendor/mtp-rs/src/mtp/event.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Device events. - -use crate::ptp::{EventCode, EventContainer, ObjectHandle, StorageId}; - -/// Events from an MTP device. -#[derive(Debug, Clone)] -pub enum DeviceEvent { - /// A new object was added. - ObjectAdded { - /// Handle of the new object. - handle: ObjectHandle, - }, - - /// An object was removed. - ObjectRemoved { - /// Handle of the removed object. - handle: ObjectHandle, - }, - - /// A storage was added (e.g., SD card inserted). - StoreAdded { - /// ID of the new storage. - storage_id: StorageId, - }, - - /// A storage was removed. - StoreRemoved { - /// ID of the removed storage. - storage_id: StorageId, - }, - - /// Storage info changed (e.g., free space). - StorageInfoChanged { - /// ID of the storage that changed. - storage_id: StorageId, - }, - - /// Object info changed. - ObjectInfoChanged { - /// Handle of the object that changed. - handle: ObjectHandle, - }, - - /// Device info changed. - DeviceInfoChanged, - - /// Device is being reset. - DeviceReset, - - /// Unknown event. - Unknown { - /// Raw event code. - code: u16, - /// Event parameters. - params: [u32; 3], - }, -} - -impl DeviceEvent { - /// Parse from an event container. - #[must_use] - pub fn from_container(container: &EventContainer) -> Self { - match container.code { - EventCode::ObjectAdded => DeviceEvent::ObjectAdded { - handle: ObjectHandle(container.params[0]), - }, - EventCode::ObjectRemoved => DeviceEvent::ObjectRemoved { - handle: ObjectHandle(container.params[0]), - }, - EventCode::StoreAdded => DeviceEvent::StoreAdded { - storage_id: StorageId(container.params[0]), - }, - EventCode::StoreRemoved => DeviceEvent::StoreRemoved { - storage_id: StorageId(container.params[0]), - }, - EventCode::StorageInfoChanged => DeviceEvent::StorageInfoChanged { - storage_id: StorageId(container.params[0]), - }, - EventCode::ObjectInfoChanged => DeviceEvent::ObjectInfoChanged { - handle: ObjectHandle(container.params[0]), - }, - EventCode::DeviceInfoChanged => DeviceEvent::DeviceInfoChanged, - // All other codes (including Unknown and unhandled known codes like DevicePropChanged) - other => DeviceEvent::Unknown { - code: other.into(), - params: container.params, - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn event_parsing() { - // Events with object handle param - for (code, expected_handle) in [ - (EventCode::ObjectAdded, 42), - (EventCode::ObjectRemoved, 123), - (EventCode::ObjectInfoChanged, 99), - ] { - let container = EventContainer { - code, - transaction_id: 0, - params: [expected_handle, 0, 0], - }; - let event = DeviceEvent::from_container(&container); - let handle = match event { - DeviceEvent::ObjectAdded { handle } => handle, - DeviceEvent::ObjectRemoved { handle } => handle, - DeviceEvent::ObjectInfoChanged { handle } => handle, - _ => panic!("Unexpected event type"), - }; - assert_eq!(handle, ObjectHandle(expected_handle)); - } - - // Events with storage ID param - for (code, expected_id) in [ - (EventCode::StoreAdded, 0x00010001), - (EventCode::StoreRemoved, 0x00010002), - (EventCode::StorageInfoChanged, 0x00010001), - ] { - let container = EventContainer { - code, - transaction_id: 0, - params: [expected_id, 0, 0], - }; - let event = DeviceEvent::from_container(&container); - let storage_id = match event { - DeviceEvent::StoreAdded { storage_id } => storage_id, - DeviceEvent::StoreRemoved { storage_id } => storage_id, - DeviceEvent::StorageInfoChanged { storage_id } => storage_id, - _ => panic!("Unexpected event type"), - }; - assert_eq!(storage_id, StorageId(expected_id)); - } - - // DeviceInfoChanged (no params) - let container = EventContainer { - code: EventCode::DeviceInfoChanged, - transaction_id: 0, - params: [0, 0, 0], - }; - assert!(matches!( - DeviceEvent::from_container(&container), - DeviceEvent::DeviceInfoChanged - )); - } - - #[test] - fn unknown_events() { - // Explicit Unknown code - let container = EventContainer { - code: EventCode::Unknown(0x9999), - transaction_id: 0, - params: [1, 2, 3], - }; - match DeviceEvent::from_container(&container) { - DeviceEvent::Unknown { code, params } => { - assert_eq!(code, 0x9999); - assert_eq!(params, [1, 2, 3]); - } - _ => panic!("Expected Unknown event"), - } - - // Known EventCode without DeviceEvent variant (DevicePropChanged) - let container = EventContainer { - code: EventCode::DevicePropChanged, - transaction_id: 0, - params: [100, 0, 0], - }; - match DeviceEvent::from_container(&container) { - DeviceEvent::Unknown { code, params } => { - assert_eq!(code, 0x4006); - assert_eq!(params[0], 100); - } - _ => panic!("Expected Unknown event"), - } - } -} diff --git a/vendor/mtp-rs/src/mtp/mod.rs b/vendor/mtp-rs/src/mtp/mod.rs deleted file mode 100644 index 81220fa..0000000 --- a/vendor/mtp-rs/src/mtp/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! High-level MTP (Media Transfer Protocol) API for Android devices and media players. -//! -//! This module provides a convenient, batteries-included API for common file transfer -//! operations. Use this module when: -//! -//! - Working with Android phones and tablets -//! - You want simple file listing, upload, and download -//! - You need storage enumeration and device info -//! - You don't need camera-specific features (capture, live view, etc.) -//! -//! ## When to use `ptp` instead -//! -//! Use the lower-level [`crate::ptp`] module when you need: -//! - Direct control over PTP operations and transactions -//! - Camera-specific functionality -//! - Custom protocol extensions -//! - Access to raw response codes and error details -//! -//! ## Quick example -//! -//! ```rust,no_run -//! use mtp_rs::mtp::MtpDevice; -//! -//! # async fn example() -> Result<(), mtp_rs::Error> { -//! let device = MtpDevice::open_first().await?; -//! for storage in device.storages().await? { -//! for obj in storage.list_objects(None).await? { -//! println!("{}", obj.filename); -//! } -//! } -//! # Ok(()) -//! # } -//! ``` - -mod device; -mod event; -mod object; -mod storage; -mod stream; - -pub use device::{MtpDevice, MtpDeviceBuilder, MtpDeviceInfo}; -pub use event::DeviceEvent; -pub use object::NewObjectInfo; -pub use storage::{ObjectListing, Storage}; -pub use stream::{FileDownload, Progress, DEFAULT_CANCEL_TIMEOUT}; diff --git a/vendor/mtp-rs/src/mtp/object.rs b/vendor/mtp-rs/src/mtp/object.rs deleted file mode 100644 index bc4a0e3..0000000 --- a/vendor/mtp-rs/src/mtp/object.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Object-related types for MTP. - -use crate::ptp::{AssociationType, DateTime, ObjectFormatCode, ObjectInfo as PtpObjectInfo}; - -/// Information needed to create a new object. -#[derive(Debug, Clone)] -pub struct NewObjectInfo { - /// Filename (max 254 characters, no /, \, or null bytes) - pub filename: String, - /// File size in bytes (must match actual data sent) - pub size: u64, - /// Object format (auto-detected from extension if None) - pub format: Option, - /// Modification time - pub modified: Option, -} - -impl NewObjectInfo { - /// Create info for a file. Format auto-detected from extension. - #[must_use] - pub fn file(filename: impl Into, size: u64) -> Self { - let filename = filename.into(); - let format = detect_format_from_filename(&filename); - Self { - filename, - size, - format: Some(format), - modified: None, - } - } - - /// Create info for a folder. - #[must_use] - pub fn folder(name: impl Into) -> Self { - Self { - filename: name.into(), - size: 0, - format: Some(ObjectFormatCode::Association), - modified: None, - } - } - - /// Create info with explicit format. - #[must_use] - pub fn with_format(filename: impl Into, size: u64, format: ObjectFormatCode) -> Self { - Self { - filename: filename.into(), - size, - format: Some(format), - modified: None, - } - } - - /// Set modification time. - #[must_use] - pub fn with_modified(mut self, modified: DateTime) -> Self { - self.modified = Some(modified); - self - } - - /// Convert to PTP ObjectInfo for sending. - pub(crate) fn to_object_info(&self) -> PtpObjectInfo { - let format = self.format.unwrap_or(ObjectFormatCode::Undefined); - let is_folder = format == ObjectFormatCode::Association; - - PtpObjectInfo { - format, - size: self.size, - filename: self.filename.clone(), - modified: self.modified, - association_type: if is_folder { - AssociationType::GenericFolder - } else { - AssociationType::None - }, - ..Default::default() - } - } -} - -/// Detect format from filename extension. -fn detect_format_from_filename(filename: &str) -> ObjectFormatCode { - if let Some(ext) = filename.rsplit('.').next() { - ObjectFormatCode::from_extension(ext) - } else { - ObjectFormatCode::Undefined - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_object_info_file() { - let info = NewObjectInfo::file("test.mp3", 1000); - assert_eq!(info.filename, "test.mp3"); - assert_eq!(info.size, 1000); - assert_eq!(info.format, Some(ObjectFormatCode::Mp3)); - } - - #[test] - fn test_new_object_info_folder() { - let info = NewObjectInfo::folder("Music"); - assert_eq!(info.filename, "Music"); - assert_eq!(info.size, 0); - assert_eq!(info.format, Some(ObjectFormatCode::Association)); - } - - #[test] - fn test_format_detection() { - assert_eq!( - detect_format_from_filename("song.mp3"), - ObjectFormatCode::Mp3 - ); - assert_eq!( - detect_format_from_filename("photo.jpg"), - ObjectFormatCode::Jpeg - ); - assert_eq!( - detect_format_from_filename("video.mp4"), - ObjectFormatCode::Mp4Container - ); - assert_eq!( - detect_format_from_filename("unknown.xyz"), - ObjectFormatCode::Undefined - ); - } - - #[test] - fn test_with_format() { - let info = NewObjectInfo::with_format("document.bin", 500, ObjectFormatCode::Executable); - assert_eq!(info.filename, "document.bin"); - assert_eq!(info.size, 500); - assert_eq!(info.format, Some(ObjectFormatCode::Executable)); - } - - #[test] - fn test_with_modified() { - let dt = DateTime { - year: 2024, - month: 6, - day: 15, - hour: 10, - minute: 30, - second: 0, - }; - let info = NewObjectInfo::file("test.txt", 100).with_modified(dt); - assert_eq!(info.modified, Some(dt)); - } - - #[test] - fn test_to_object_info_file() { - let info = NewObjectInfo::file("test.mp3", 1000); - let ptp_info = info.to_object_info(); - - assert_eq!(ptp_info.format, ObjectFormatCode::Mp3); - assert_eq!(ptp_info.size, 1000); - assert_eq!(ptp_info.filename, "test.mp3"); - assert_eq!(ptp_info.association_type, AssociationType::None); - } - - #[test] - fn test_to_object_info_folder() { - let info = NewObjectInfo::folder("Music"); - let ptp_info = info.to_object_info(); - - assert_eq!(ptp_info.format, ObjectFormatCode::Association); - assert_eq!(ptp_info.size, 0); - assert_eq!(ptp_info.filename, "Music"); - assert_eq!(ptp_info.association_type, AssociationType::GenericFolder); - } - - #[test] - fn test_format_detection_case_insensitive() { - // The from_extension method is case-insensitive - assert_eq!( - detect_format_from_filename("SONG.MP3"), - ObjectFormatCode::Mp3 - ); - assert_eq!( - detect_format_from_filename("Photo.JPG"), - ObjectFormatCode::Jpeg - ); - } - - #[test] - fn test_format_detection_no_extension() { - assert_eq!( - detect_format_from_filename("noextension"), - ObjectFormatCode::Undefined - ); - } -} diff --git a/vendor/mtp-rs/src/mtp/storage.rs b/vendor/mtp-rs/src/mtp/storage.rs deleted file mode 100644 index 76ce654..0000000 --- a/vendor/mtp-rs/src/mtp/storage.rs +++ /dev/null @@ -1,1112 +0,0 @@ -//! Storage operations. - -use crate::mtp::object::NewObjectInfo; -use crate::mtp::stream::{FileDownload, Progress}; -use crate::ptp::{ObjectHandle, ObjectInfo, StorageId, StorageInfo}; -use crate::Error; -use bytes::Bytes; -use futures::Stream; -use std::ops::ControlFlow; -use std::sync::Arc; - -use super::device::MtpDeviceInner; - -/// An in-progress directory listing that yields [`ObjectInfo`] items one at a time. -/// -/// Created by [`Storage::list_objects_stream()`]. After `GetObjectHandles` completes, -/// the total count is known immediately. Each call to [`next()`](Self::next) fetches -/// one `GetObjectInfo` from USB, so the consumer can report progress (e.g., -/// "Loading files (42 of 500)...") as items arrive. -/// -/// # Important -/// -/// The MTP session is busy while this listing is active. You must consume -/// all items (or drop the listing) before calling other storage methods. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::mtp::MtpDevice; -/// -/// # async fn example() -> Result<(), mtp_rs::Error> { -/// # let device = MtpDevice::open_first().await?; -/// # let storages = device.storages().await?; -/// # let storage = &storages[0]; -/// let mut listing = storage.list_objects_stream(None).await?; -/// println!("Loading {} files...", listing.total()); -/// -/// while let Some(result) = listing.next().await { -/// let info = result?; -/// println!("[{}/{}] {}", listing.fetched(), listing.total(), info.filename); -/// } -/// # Ok(()) -/// # } -/// ``` -pub struct ObjectListing { - inner: Arc, - handles: Vec, - /// Index of the next handle to fetch. - cursor: usize, - /// Parent filter: if Some, only items matching this parent are yielded. - parent_filter: Option, -} - -/// Describes how to filter objects by parent handle. -enum ParentFilter { - /// Accept objects whose parent matches exactly. - Exact(ObjectHandle), - /// Android root: accept parent 0 or 0xFFFFFFFF. - AndroidRoot, -} - -impl ObjectListing { - /// Total number of object handles returned by the device. - /// - /// When a parent filter is active (e.g., Fuji devices that return all objects - /// for root), some items may be skipped, so the actual yielded count can be lower. - #[must_use] - pub fn total(&self) -> usize { - self.handles.len() - } - - /// Number of handles processed so far (including filtered-out items). - #[must_use] - pub fn fetched(&self) -> usize { - self.cursor - } - - /// Fetch the next object from the device. - /// - /// Returns `None` when all handles have been processed. - /// Items that don't match the parent filter are silently skipped. - pub async fn next(&mut self) -> Option> { - loop { - if self.cursor >= self.handles.len() { - return None; - } - - let handle = self.handles[self.cursor]; - self.cursor += 1; - - let mut info = match self.inner.session.get_object_info_full(handle).await { - Ok(info) => info, - Err(e) => return Some(Err(e)), - }; - info.handle = handle; - - // Apply parent filter if present - if let Some(filter) = &self.parent_filter { - let matches = match filter { - ParentFilter::Exact(expected) => info.parent == *expected, - ParentFilter::AndroidRoot => info.parent.0 == 0 || info.parent.0 == 0xFFFFFFFF, - }; - if !matches { - continue; - } - } - - return Some(Ok(info)); - } - } -} - -/// A storage location on an MTP device. -/// -/// `Storage` holds an `Arc` so it can outlive the original -/// `MtpDevice` and be used from multiple tasks. -pub struct Storage { - inner: Arc, - id: StorageId, - info: StorageInfo, -} - -impl Storage { - /// Create a new Storage (internal). - pub(crate) fn new(inner: Arc, id: StorageId, info: StorageInfo) -> Self { - Self { inner, id, info } - } - - #[must_use] - pub fn id(&self) -> StorageId { - self.id - } - - /// Storage information (cached, call refresh() to update). - #[must_use] - pub fn info(&self) -> &StorageInfo { - &self.info - } - - /// Refresh storage info from device (updates free space, etc.). - pub async fn refresh(&mut self) -> Result<(), Error> { - self.info = self.inner.session.get_storage_info(self.id).await?; - Ok(()) - } - - /// List objects in a folder (None = root), returning all results at once. - /// - /// For progress reporting during large listings, use - /// [`list_objects_stream()`](Self::list_objects_stream) instead. - /// - /// This method handles various device quirks: - /// - Root listing tries parent=0xFFFFFFFF first (fast path for Android, Kindle, etc.) - /// - Falls back to parent=0 only when the device rejects 0xFFFFFFFF - /// - Samsung devices: return InvalidObjectHandle for parent=0, so we fall back to recursive - /// - Fuji devices: return all objects for root, so we filter by parent handle - pub async fn list_objects( - &self, - parent: Option, - ) -> Result, Error> { - let mut listing = self.list_objects_stream(parent).await?; - let mut objects = Vec::with_capacity(listing.total()); - while let Some(result) = listing.next().await { - objects.push(result?); - } - Ok(objects) - } - - /// List objects in a folder as a streaming [`ObjectListing`]. - /// - /// Returns immediately after `GetObjectHandles` completes. The total count - /// is then known via [`ObjectListing::total()`], and each call to - /// [`ObjectListing::next()`] fetches one object's metadata from USB. - /// - /// For root listings (`parent=None`), tries `parent=0xFFFFFFFF` first — this - /// returns only root-level handles on Android, Kindle, and many other devices. - /// Falls back to `parent=0` only when the device rejects `0xFFFFFFFF` with an - /// error. An empty result from `0xFFFFFFFF` is treated as an empty storage, - /// not as a reason to fall back. - /// - /// This enables progress reporting (e.g., "Loading 42 of 500...") during - /// what would otherwise be a single blocking `list_objects()` call. - /// - /// Handles the same device quirks as [`list_objects()`](Self::list_objects). - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// # let device = MtpDevice::open_first().await?; - /// # let storages = device.storages().await?; - /// # let storage = &storages[0]; - /// let mut listing = storage.list_objects_stream(None).await?; - /// println!("Found {} items", listing.total()); - /// - /// while let Some(result) = listing.next().await { - /// let info = result?; - /// println!("[{}/{}] {}", listing.fetched(), listing.total(), info.filename); - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn list_objects_stream( - &self, - parent: Option, - ) -> Result { - // For root listings, try parent=0xFFFFFFFF first. Many devices (Android, - // Kindle, others) return only root-level handles for this value, while - // parent=0 returns every object on the storage. Fall back to parent=0 - // only when the device rejects 0xFFFFFFFF with an error. - if parent.is_none() { - let fast = self - .inner - .session - .get_object_handles(self.id, None, Some(ObjectHandle::ALL)) - .await; - - match fast { - Ok(handles) => { - return Ok(ObjectListing { - inner: Arc::clone(&self.inner), - handles, - cursor: 0, - parent_filter: Some(ParentFilter::AndroidRoot), - }); - } - Err(_) => { - // 0xFFFFFFFF rejected; fall through to parent=0 path - } - } - } - - let result = self - .inner - .session - .get_object_handles(self.id, None, parent) - .await; - - let handles = match result { - Ok(h) => h, - Err(Error::Protocol { - code: crate::ptp::ResponseCode::InvalidObjectHandle, - .. - }) if parent.is_none() => { - // Samsung fallback: use recursive listing and filter to root items - return self.list_objects_stream_samsung_fallback().await; - } - Err(e) => return Err(e), - }; - - let parent_filter = Some(ParentFilter::Exact(parent.unwrap_or(ObjectHandle::ROOT))); - - Ok(ObjectListing { - inner: Arc::clone(&self.inner), - handles, - cursor: 0, - parent_filter, - }) - } - - /// Samsung fallback returning a streaming [`ObjectListing`]. - async fn list_objects_stream_samsung_fallback(&self) -> Result { - let handles = self - .inner - .session - .get_object_handles(self.id, None, Some(ObjectHandle::ALL)) - .await?; - - Ok(ObjectListing { - inner: Arc::clone(&self.inner), - handles, - cursor: 0, - // Root items have parent 0 or 0xFFFFFFFF (depending on device) - parent_filter: Some(ParentFilter::AndroidRoot), - }) - } - - /// List objects recursively. - /// - /// This method automatically detects Android devices and uses manual traversal - /// for them, since Android's MTP implementation doesn't support the native - /// `ObjectHandle::ALL` recursive listing. - /// - /// For non-Android devices, it tries native recursive listing first and falls - /// back to manual traversal if the results look incomplete. - pub async fn list_objects_recursive( - &self, - parent: Option, - ) -> Result, Error> { - if self.inner.is_android() { - return self.list_objects_recursive_manual(parent).await; - } - - let native_result = self.list_objects_recursive_native(parent).await?; - - // Heuristic: if we only got folders and no files, native listing - // probably didn't work - fall back to manual traversal - let has_files = native_result.iter().any(|o| o.is_file()); - if !native_result.is_empty() && !has_files { - return self.list_objects_recursive_manual(parent).await; - } - - Ok(native_result) - } - - /// List objects recursively using native MTP recursive listing. - pub async fn list_objects_recursive_native( - &self, - parent: Option, - ) -> Result, Error> { - let recursive_parent = if parent.is_none() { - Some(ObjectHandle::ALL) - } else { - parent - }; - - let handles = self - .inner - .session - .get_object_handles(self.id, None, recursive_parent) - .await?; - - let mut objects = Vec::with_capacity(handles.len()); - for handle in handles { - let mut info = self.inner.session.get_object_info_full(handle).await?; - info.handle = handle; - objects.push(info); - } - Ok(objects) - } - - /// List objects recursively using manual folder traversal. - pub async fn list_objects_recursive_manual( - &self, - parent: Option, - ) -> Result, Error> { - let mut result = Vec::new(); - let mut folders_to_visit = vec![parent]; - - while let Some(current_parent) = folders_to_visit.pop() { - let objects = self.list_objects(current_parent).await?; - - for obj in objects { - if obj.is_folder() { - folders_to_visit.push(Some(obj.handle)); - } - result.push(obj); - } - } - - Ok(result) - } - - /// Get object metadata by handle. - /// - /// Files larger than 4 GB have their u64 size auto-resolved via - /// `GetObjectPropValue(ObjectSize)`; the standard `ObjectInfo` dataset - /// only encodes a u32 size which saturates at 4 GB - 1. - pub async fn get_object_info(&self, handle: ObjectHandle) -> Result { - let mut info = self.inner.session.get_object_info_full(handle).await?; - info.handle = handle; - Ok(info) - } - - // ========================================================================= - // Download operations - // ========================================================================= - - /// Download a file and return all bytes. - /// - /// For small to medium files where you want all the data in memory. - /// For large files or streaming to disk, use [`download_stream()`](Self::download_stream). - pub async fn download(&self, handle: ObjectHandle) -> Result, Error> { - self.inner.session.get_object(handle).await - } - - /// Download a partial file (byte range). - /// - /// Uses the standard `GetPartialObject` operation, which has a 32-bit offset. - /// Offsets beyond 4 GB will be silently truncated — for files larger than 4 GB, - /// use [`download_partial_64()`](Self::download_partial_64) instead. - pub async fn download_partial( - &self, - handle: ObjectHandle, - offset: u64, - size: u32, - ) -> Result, Error> { - self.inner - .session - .get_partial_object(handle, offset, size) - .await - } - - /// Download a partial file (byte range) with 64-bit offset support. - /// - /// Uses the Android/MTP extension `GetPartialObject64`, which supports offsets - /// beyond 4 GB. Only works on devices that advertise support for it (most modern - /// Android devices do); others return `OperationNotSupported`. - pub async fn download_partial_64( - &self, - handle: ObjectHandle, - offset: u64, - size: u32, - ) -> Result, Error> { - self.inner - .session - .get_partial_object_64(handle, offset, size) - .await - } - - pub async fn download_thumbnail(&self, handle: ObjectHandle) -> Result, Error> { - self.inner.session.get_thumb(handle).await - } - - /// Download a file as a stream (true USB streaming). - /// - /// Unlike [`download()`](Self::download), this method yields data chunks - /// directly from USB as they arrive, without buffering the entire file - /// in memory. Ideal for large files or when piping data to disk. - /// - /// # Important - /// - /// The MTP session is locked while the download is active. You must either - /// consume the entire download or call [`FileDownload::cancel()`] before - /// dropping it. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// use mtp_rs::ObjectHandle; - /// use tokio::io::AsyncWriteExt; - /// - /// # async fn example() -> Result<(), Box> { - /// # let device = MtpDevice::open_first().await?; - /// # let storages = device.storages().await?; - /// # let storage = &storages[0]; - /// # let handle = ObjectHandle(1); - /// let mut download = storage.download_stream(handle).await?; - /// println!("Downloading {} bytes...", download.size()); - /// - /// let mut file = tokio::fs::File::create("output.bin").await?; - /// while let Some(chunk) = download.next_chunk().await { - /// let bytes = chunk?; - /// file.write_all(&bytes).await?; - /// println!("Progress: {:.1}%", download.progress() * 100.0); - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn download_stream(&self, handle: ObjectHandle) -> Result { - let info = self.get_object_info(handle).await?; - let size = info.size; - - let stream = self - .inner - .session - .execute_with_receive_stream(crate::ptp::OperationCode::GetObject, &[handle.0]) - .await?; - - Ok(FileDownload::new(size, stream)) - } - - // ========================================================================= - // Upload operations - // ========================================================================= - - /// Upload a file from a stream. - /// - /// The stream is consumed and all data is buffered before sending - /// (MTP protocol requires knowing the total size upfront). - /// - /// # Arguments - /// - /// * `parent` - Parent folder handle (None for root) - /// * `info` - Object metadata including filename and size - /// * `data` - Stream of data chunks to upload - pub async fn upload( - &self, - parent: Option, - info: NewObjectInfo, - data: S, - ) -> Result - where - S: Stream> + Unpin + Send, - { - self.upload_with_progress(parent, info, data, |_| ControlFlow::Continue(())) - .await - } - - /// Upload a file with progress callback. - /// - /// Progress is reported as data is read from the stream. Return - /// `ControlFlow::Break(())` from the callback to cancel the upload. - pub async fn upload_with_progress( - &self, - parent: Option, - info: NewObjectInfo, - data: S, - mut on_progress: F, - ) -> Result - where - S: Stream> + Unpin + Send, - F: FnMut(Progress) -> ControlFlow<()> + Send, - { - use futures::StreamExt; - - let total_size = info.size; - let object_info = info.to_object_info(); - let parent_handle = parent.unwrap_or(ObjectHandle::ROOT); - - let (_, _, handle) = self - .inner - .session - .send_object_info(self.id, parent_handle, &object_info) - .await?; - - // Wrap the stream to report progress and support cancellation. - let mut bytes_sent = 0u64; - let progress_stream = data.map(move |chunk_result| { - let chunk = chunk_result?; - bytes_sent += chunk.len() as u64; - let progress = Progress { - bytes_transferred: bytes_sent, - total_bytes: Some(total_size), - }; - if let ControlFlow::Break(()) = on_progress(progress) { - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "cancelled", - )); - } - Ok(chunk) - }); - - self.inner - .session - .send_object_stream(total_size, progress_stream) - .await - .map_err(|e| match &e { - Error::Io(io_err) if io_err.kind() == std::io::ErrorKind::Interrupted => { - Error::Cancelled - } - _ => e, - })?; - - Ok(handle) - } - - // ========================================================================= - // Folder and object management - // ========================================================================= - - pub async fn create_folder( - &self, - parent: Option, - name: &str, - ) -> Result { - let info = NewObjectInfo::folder(name); - let object_info = info.to_object_info(); - let parent_handle = parent.unwrap_or(ObjectHandle::ROOT); - - let (_, _, handle) = self - .inner - .session - .send_object_info(self.id, parent_handle, &object_info) - .await?; - - Ok(handle) - } - - pub async fn delete(&self, handle: ObjectHandle) -> Result<(), Error> { - self.inner.session.delete_object(handle).await - } - - /// Move an object to a different folder. - pub async fn move_object( - &self, - handle: ObjectHandle, - new_parent: ObjectHandle, - new_storage: Option, - ) -> Result<(), Error> { - let storage = new_storage.unwrap_or(self.id); - self.inner - .session - .move_object(handle, storage, new_parent) - .await - } - - pub async fn copy_object( - &self, - handle: ObjectHandle, - new_parent: ObjectHandle, - new_storage: Option, - ) -> Result { - let storage = new_storage.unwrap_or(self.id); - self.inner - .session - .copy_object(handle, storage, new_parent) - .await - } - - /// Rename an object (file or folder). - /// - /// Not all devices support renaming. Use `MtpDevice::supports_rename()` - /// to check if this operation is available. - pub async fn rename(&self, handle: ObjectHandle, new_name: &str) -> Result<(), Error> { - self.inner.session.rename_object(handle, new_name).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::{ - pack_u16, pack_u32, pack_u32_array, ContainerType, DateTime, DeviceInfo, ObjectFormatCode, - OperationCode, PtpSession, ResponseCode, StorageInfo, - }; - use crate::transport::mock::MockTransport; - - // -- Test helpers (same protocol-level helpers as session tests) ----------- - - fn mock_transport() -> (Arc, Arc) { - let mock = Arc::new(MockTransport::new()); - let transport: Arc = Arc::clone(&mock) as _; - (transport, mock) - } - - fn ok_response(tx_id: u32) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(12)); - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf - } - - fn error_response(tx_id: u32, code: ResponseCode) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(12)); - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf - } - - fn data_container(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { - let len = 12 + payload.len(); - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf.extend_from_slice(payload); - buf - } - - // -- Storage-level helpers ------------------------------------------------ - - /// Build a Storage backed by a mock transport for testing. - /// - /// Queues the OpenSession response automatically. The caller must queue - /// further responses before calling list methods. - async fn mock_storage( - transport: Arc, - vendor_extension_desc: &str, - ) -> Storage { - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let inner = Arc::new(MtpDeviceInner { - session, - device_info: DeviceInfo { - vendor_extension_desc: vendor_extension_desc.to_string(), - ..DeviceInfo::default() - }, - }); - Storage::new(inner, StorageId(1), StorageInfo::default()) - } - - /// Build a minimal ObjectInfo binary payload with a given filename and parent. - fn object_info_bytes(filename: &str, parent: u32) -> Vec { - let info = ObjectInfo { - storage_id: StorageId(1), - format: ObjectFormatCode::Jpeg, - parent: ObjectHandle(parent), - filename: filename.to_string(), - created: Some(DateTime { - year: 2024, - month: 1, - day: 1, - hour: 0, - minute: 0, - second: 0, - }), - ..ObjectInfo::default() - }; - info.to_bytes().unwrap() - } - - /// Queue GetObjectHandles response (data + ok) for a given transaction ID. - fn queue_handles(mock: &MockTransport, tx_id: u32, handles: &[u32]) { - let data = pack_u32_array(handles); - mock.queue_response(data_container( - tx_id, - OperationCode::GetObjectHandles, - &data, - )); - mock.queue_response(ok_response(tx_id)); - } - - /// Queue GetObjectInfo response (data + ok) for a given transaction ID. - fn queue_object_info(mock: &MockTransport, tx_id: u32, filename: &str, parent: u32) { - let data = object_info_bytes(filename, parent); - mock.queue_response(data_container(tx_id, OperationCode::GetObjectInfo, &data)); - mock.queue_response(ok_response(tx_id)); - } - - // -- Tests ---------------------------------------------------------------- - - #[tokio::test] - async fn stream_returns_objects_with_correct_counters() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[10, 20, 30]); - queue_object_info(&mock, 2, "photo.jpg", 0); - queue_object_info(&mock, 3, "video.mp4", 0); - queue_object_info(&mock, 4, "notes.txt", 0); - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 3); - assert_eq!(listing.fetched(), 0); - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "photo.jpg"); - assert_eq!(first.handle, ObjectHandle(10)); - assert_eq!(listing.fetched(), 1); - - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "video.mp4"); - assert_eq!(second.handle, ObjectHandle(20)); - assert_eq!(listing.fetched(), 2); - - let third = listing.next().await.unwrap().unwrap(); - assert_eq!(third.filename, "notes.txt"); - assert_eq!(third.handle, ObjectHandle(30)); - assert_eq!(listing.fetched(), 3); - - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_empty_directory() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_handles(&mock, 1, &[]); - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 0); - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_filters_by_parent() { - // Root fast path (0xFFFFFFFF) uses AndroidRoot filter, which rejects - // objects whose parent is neither 0 nor 0xFFFFFFFF. - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[10, 20, 30]); - queue_object_info(&mock, 2, "root_file.jpg", 0); // parent=ROOT, included - queue_object_info(&mock, 3, "nested.jpg", 99); // parent=99, filtered out - queue_object_info(&mock, 4, "another_root.txt", 0); // parent=ROOT, included - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 3); // Total handles from device - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "root_file.jpg"); - assert_eq!(listing.fetched(), 1); - - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "another_root.txt"); - assert_eq!(listing.fetched(), 3); // Processed all 3 (including filtered one) - - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_root_accepts_both_parent_values() { - // Root fast path uses AndroidRoot filter: accepts parent 0 or 0xFFFFFFFF - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[10, 20, 30]); - queue_object_info(&mock, 2, "dcim", 0); // parent=0, root - queue_object_info(&mock, 3, "download", 0xFFFFFFFF); // parent=ALL, also root - queue_object_info(&mock, 4, "nested", 42); // not root - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "dcim"); - - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "download"); - - assert!(listing.next().await.is_none()); // "nested" filtered out - } - - #[tokio::test] - async fn stream_subfolder_listing() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let parent_handle = 42u32; - queue_handles(&mock, 1, &[100, 101]); - queue_object_info(&mock, 2, "IMG_001.jpg", parent_handle); - queue_object_info(&mock, 3, "IMG_002.jpg", parent_handle); - - let storage = mock_storage(transport, "").await; - let mut listing = storage - .list_objects_stream(Some(ObjectHandle(parent_handle))) - .await - .unwrap(); - - assert_eq!(listing.total(), 2); - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "IMG_001.jpg"); - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "IMG_002.jpg"); - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_propagates_mid_listing_error() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[10, 20]); - queue_object_info(&mock, 2, "good.jpg", 0); - // Handle 20: device returns error instead of object info - mock.queue_response(error_response(3, ResponseCode::InvalidObjectHandle)); - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "good.jpg"); - - let second = listing.next().await.unwrap(); - assert!(second.is_err()); - } - - #[tokio::test] - async fn list_objects_matches_stream_collect() { - // Verify list_objects() returns identical results to collecting the stream - let (transport1, mock1) = mock_transport(); - let (transport2, mock2) = mock_transport(); - - for mock in [&mock1, &mock2] { - mock.queue_response(ok_response(0)); // OpenSession - queue_handles(mock, 1, &[10, 20]); - queue_object_info(mock, 2, "a.jpg", 0); - queue_object_info(mock, 3, "b.txt", 0); - } - - let storage1 = mock_storage(transport1, "").await; - let storage2 = mock_storage(transport2, "").await; - - let all_at_once = storage1.list_objects(None).await.unwrap(); - - let mut listing = storage2.list_objects_stream(None).await.unwrap(); - let mut streamed = Vec::new(); - while let Some(result) = listing.next().await { - streamed.push(result.unwrap()); - } - - assert_eq!(all_at_once.len(), streamed.len()); - for (a, b) in all_at_once.iter().zip(streamed.iter()) { - assert_eq!(a.filename, b.filename); - assert_eq!(a.handle, b.handle); - } - } - - // -- Root listing fast-path / fallback tests -------------------------------- - - #[tokio::test] - async fn stream_root_falls_back_on_error() { - // When 0xFFFFFFFF is rejected, falls through to parent=0 with Exact(ROOT) - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Fast path (0xFFFFFFFF): device rejects with InvalidObjectHandle - mock.queue_response(error_response(1, ResponseCode::InvalidObjectHandle)); - - // Fallback path (parent=0): returns root objects - queue_handles(&mock, 2, &[10, 20]); - queue_object_info(&mock, 3, "root.jpg", 0); - queue_object_info(&mock, 4, "nested.jpg", 99); // filtered by Exact(ROOT) - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 2); - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "root.jpg"); - - // nested.jpg has parent=99, filtered out by Exact(ROOT) - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_root_empty_is_not_fallback() { - // Empty Ok([]) from 0xFFFFFFFF is a valid empty storage, not a fallback trigger - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[]); // fast path returns empty - - let storage = mock_storage(transport, "").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 0); - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_kindle_root_uses_fast_path() { - // Non-Android device (Kindle) benefits from 0xFFFFFFFF fast path - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - queue_handles(&mock, 1, &[100, 101, 102]); - queue_object_info(&mock, 2, "documents", 0); - queue_object_info(&mock, 3, "system", 0); - queue_object_info(&mock, 4, "fonts", 0); - - let storage = mock_storage(transport, "microsoft.com/WMDRMPD:10.1").await; - let mut listing = storage.list_objects_stream(None).await.unwrap(); - - assert_eq!(listing.total(), 3); - - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "documents"); - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "system"); - let third = listing.next().await.unwrap().unwrap(); - assert_eq!(third.filename, "fonts"); - - assert!(listing.next().await.is_none()); - } - - #[tokio::test] - async fn stream_subfolder_skips_fast_path() { - // Subfolder listing should not attempt 0xFFFFFFFF; only one get_object_handles call - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let parent_handle = 50u32; - queue_handles(&mock, 1, &[200, 201]); - queue_object_info(&mock, 2, "child_a.txt", parent_handle); - queue_object_info(&mock, 3, "child_b.txt", parent_handle); - - let storage = mock_storage(transport, "").await; - let mut listing = storage - .list_objects_stream(Some(ObjectHandle(parent_handle))) - .await - .unwrap(); - - assert_eq!(listing.total(), 2); - let first = listing.next().await.unwrap().unwrap(); - assert_eq!(first.filename, "child_a.txt"); - let second = listing.next().await.unwrap().unwrap(); - assert_eq!(second.filename, "child_b.txt"); - assert!(listing.next().await.is_none()); - } - - // -- Full-size (>4 GB) resolution via GetObjectPropValue ------------------ - - /// Build an ObjectInfo payload with a specific `size`. Sizes > u32::MAX are - /// serialized as u32::MAX by `ObjectInfo::to_bytes`, matching real-device behavior. - fn object_info_bytes_with_size(filename: &str, parent: u32, size: u64) -> Vec { - let info = ObjectInfo { - storage_id: StorageId(1), - format: ObjectFormatCode::Jpeg, - parent: ObjectHandle(parent), - filename: filename.to_string(), - size, - ..ObjectInfo::default() - }; - info.to_bytes().unwrap() - } - - fn queue_object_info_with_size( - mock: &MockTransport, - tx_id: u32, - filename: &str, - parent: u32, - size: u64, - ) { - let data = object_info_bytes_with_size(filename, parent, size); - mock.queue_response(data_container(tx_id, OperationCode::GetObjectInfo, &data)); - mock.queue_response(ok_response(tx_id)); - } - - fn queue_object_size_prop(mock: &MockTransport, tx_id: u32, size: u64) { - let payload = crate::ptp::pack_u64(size); - mock.queue_response(data_container( - tx_id, - OperationCode::GetObjectPropValue, - &payload, - )); - mock.queue_response(ok_response(tx_id)); - } - - #[tokio::test] - async fn get_object_info_resolves_saturated_size() { - // Simulate a 5 GB file: ObjectInfo reports u32::MAX, GetObjectPropValue returns real u64. - const REAL_SIZE: u64 = 5 * 1024 * 1024 * 1024; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_object_info_with_size(&mock, 1, "big.mkv", 0, REAL_SIZE); - queue_object_size_prop(&mock, 2, REAL_SIZE); - - let storage = mock_storage(transport, "").await; - let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); - - assert_eq!(info.filename, "big.mkv"); - assert_eq!(info.size, REAL_SIZE, "size should be resolved to full u64"); - } - - #[tokio::test] - async fn get_object_info_skips_lookup_when_size_fits_u32() { - // Under u32::MAX: GetObjectPropValue must NOT be called. If it were, the test - // would hang or fail because we only queue one response. - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_object_info_with_size(&mock, 1, "small.jpg", 0, 1_000_000); - - let storage = mock_storage(transport, "").await; - let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); - - assert_eq!(info.size, 1_000_000); - } - - #[tokio::test] - async fn get_object_info_falls_back_when_prop_lookup_fails() { - // Device reports saturated size but doesn't support GetObjectPropValue. - // Caller should receive the saturated value rather than an error. - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_object_info_with_size(&mock, 1, "big.mkv", 0, 8 * 1024 * 1024 * 1024); - mock.queue_response(error_response(2, ResponseCode::OperationNotSupported)); - - let storage = mock_storage(transport, "").await; - let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); - - assert_eq!( - info.size, - u64::from(u32::MAX), - "should keep saturated u32::MAX when prop lookup fails" - ); - } - - #[tokio::test] - async fn get_object_info_exactly_u32_max_triggers_lookup() { - // A file whose real size happens to equal u32::MAX is ambiguous: we can't - // distinguish it from a saturated >4GB file. The lookup runs and returns the - // true size, which in this case happens to match. Verify we handle it correctly. - const REAL_SIZE: u64 = u32::MAX as u64; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_object_info_with_size(&mock, 1, "edge.bin", 0, REAL_SIZE); - queue_object_size_prop(&mock, 2, REAL_SIZE); - - let storage = mock_storage(transport, "").await; - let info = storage.get_object_info(ObjectHandle(42)).await.unwrap(); - - assert_eq!(info.size, REAL_SIZE); - } - - #[tokio::test] - async fn list_objects_stream_resolves_saturated_size() { - // Verify the fix also applies to the streaming listing path. - const REAL_SIZE: u64 = 6 * 1024 * 1024 * 1024; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - queue_handles(&mock, 1, &[10, 20]); - queue_object_info_with_size(&mock, 2, "small.jpg", 0, 500_000); - queue_object_info_with_size(&mock, 3, "huge.mkv", 0, REAL_SIZE); - queue_object_size_prop(&mock, 4, REAL_SIZE); - - let storage = mock_storage(transport, "").await; - let objects = storage.list_objects(None).await.unwrap(); - - assert_eq!(objects.len(), 2); - assert_eq!(objects[0].size, 500_000); - assert_eq!(objects[1].size, REAL_SIZE); - } -} diff --git a/vendor/mtp-rs/src/mtp/stream.rs b/vendor/mtp-rs/src/mtp/stream.rs deleted file mode 100644 index 2505680..0000000 --- a/vendor/mtp-rs/src/mtp/stream.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! Streaming download/upload support. - -use crate::ptp::ReceiveStream; -use crate::Error; -use bytes::Bytes; -use std::ops::ControlFlow; - -/// Progress information for transfers. -#[derive(Debug, Clone)] -pub struct Progress { - /// Bytes transferred so far. - pub bytes_transferred: u64, - /// Total bytes (if known). - pub total_bytes: Option, -} - -impl Progress { - /// Progress as a percentage (0.0 to 100.0). - #[must_use] - pub fn percent(&self) -> f64 { - self.fraction() * 100.0 - } - - /// Progress as a fraction (0.0 to 1.0). - #[must_use] - pub fn fraction(&self) -> f64 { - self.total_bytes.map_or(1.0, |total| { - if total == 0 { - 1.0 - } else { - self.bytes_transferred as f64 / total as f64 - } - }) - } -} - -/// Default idle timeout for cancel drain operations. -/// -/// After sending the cancel control request, this is how long we wait -/// for additional data on each pipe before assuming it's clear. Matches -/// the 300ms timeout used by libmtp, which mirrors Windows behavior. -pub const DEFAULT_CANCEL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); - -/// A file download in progress with true USB streaming. -/// -/// This struct wraps the low-level `ReceiveStream` and provides convenient -/// methods for tracking progress. Data is streamed directly from USB as -/// chunks arrive, without buffering the entire file in memory. -/// -/// # Important -/// -/// The MTP session is locked while this download is active. You must either -/// consume the entire download or call [`cancel()`](Self::cancel) before -/// dropping it. Dropping mid-download without cancelling corrupts the USB -/// session. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::mtp::MtpDevice; -/// use mtp_rs::ObjectHandle; -/// use tokio::io::AsyncWriteExt; -/// -/// # async fn example() -> Result<(), Box> { -/// # let device = MtpDevice::open_first().await?; -/// # let storages = device.storages().await?; -/// # let storage = &storages[0]; -/// # let handle = ObjectHandle(1); -/// let mut download = storage.download_stream(handle).await?; -/// println!("Downloading {} bytes...", download.size()); -/// -/// # let mut file = tokio::fs::File::create("output.bin").await?; -/// while let Some(chunk) = download.next_chunk().await { -/// let bytes = chunk?; -/// file.write_all(&bytes).await?; -/// println!("Progress: {:.1}%", download.progress() * 100.0); -/// } -/// # Ok(()) -/// # } -/// ``` -#[must_use = "dropping a FileDownload mid-transfer corrupts the USB session; \ - consume it fully or call cancel()"] -pub struct FileDownload { - size: u64, - bytes_received: u64, - stream: ReceiveStream, -} - -impl FileDownload { - /// Create a new FileDownload wrapping a ReceiveStream. - pub(crate) fn new(size: u64, stream: ReceiveStream) -> Self { - Self { - size, - bytes_received: 0, - stream, - } - } - - /// Total file size in bytes. - #[must_use] - pub fn size(&self) -> u64 { - self.size - } - - /// Bytes received so far. - #[must_use] - pub fn bytes_received(&self) -> u64 { - self.bytes_received - } - - /// Progress as a fraction (0.0 to 1.0). - #[must_use] - pub fn progress(&self) -> f64 { - if self.size == 0 { - 1.0 - } else { - self.bytes_received as f64 / self.size as f64 - } - } - - /// Cancel the in-progress download. - /// - /// Uses the USB Still Image Class cancel mechanism to stop the transfer - /// and drain remaining data, leaving the session clean for the next - /// operation. - /// - /// The `idle_timeout` controls how long to wait during pipe drain before - /// assuming the pipe is clear. 1–2 seconds is typically sufficient. - /// - /// If the download is already complete, this is a no-op. - pub async fn cancel(&mut self, idle_timeout: std::time::Duration) -> Result<(), Error> { - self.stream.cancel(idle_timeout).await - } - - /// Get the next chunk of data from USB. - /// - /// Returns `None` when the download is complete. - pub async fn next_chunk(&mut self) -> Option> { - match self.stream.next_chunk().await { - Some(Ok(bytes)) => { - self.bytes_received += bytes.len() as u64; - Some(Ok(bytes)) - } - Some(Err(e)) => Some(Err(e)), - None => None, - } - } - - /// Consume the download and iterate with a progress callback. - /// - /// Calls `on_progress` after each chunk. Return `ControlFlow::Break(())` - /// to cancel the download. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// use mtp_rs::ObjectHandle; - /// use std::ops::ControlFlow; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// # let device = MtpDevice::open_first().await?; - /// # let storages = device.storages().await?; - /// # let storage = &storages[0]; - /// # let handle = ObjectHandle(1); - /// let download = storage.download_stream(handle).await?; - /// let data = download.collect_with_progress(|progress| { - /// println!("{:.1}%", progress.percent()); - /// ControlFlow::Continue(()) - /// }).await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn collect_with_progress(mut self, mut on_progress: F) -> Result, Error> - where - F: FnMut(Progress) -> ControlFlow<()>, - { - let mut data = Vec::with_capacity(self.size as usize); - - while let Some(result) = self.next_chunk().await { - let chunk = result?; - data.extend_from_slice(&chunk); - - let progress = Progress { - bytes_transferred: self.bytes_received, - total_bytes: Some(self.size), - }; - - if let ControlFlow::Break(()) = on_progress(progress) { - self.stream.cancel(DEFAULT_CANCEL_TIMEOUT).await?; - return Err(Error::Cancelled); - } - } - - Ok(data) - } - - /// Collect all remaining data into a `Vec`. - /// - /// This consumes the download and buffers all data in memory. - pub async fn collect(self) -> Result, Error> { - self.stream.collect().await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ops::ControlFlow; - - #[test] - fn progress_calculations() { - let cases = [ - (50, Some(100), 50.0, 0.5), - (100, Some(100), 100.0, 1.0), - (25, Some(100), 25.0, 0.25), - (0, Some(0), 100.0, 1.0), // Empty file - (50, None, 100.0, 1.0), // Unknown total defaults to complete - ]; - for (transferred, total, expected_pct, expected_frac) in cases { - let p = Progress { - bytes_transferred: transferred, - total_bytes: total, - }; - assert_eq!( - p.percent(), - expected_pct, - "percent failed for {transferred}/{total:?}" - ); - assert_eq!( - p.fraction(), - expected_frac, - "fraction failed for {transferred}/{total:?}" - ); - } - - // Large numbers - let large = Progress { - bytes_transferred: u64::MAX / 2, - total_bytes: Some(u64::MAX), - }; - let frac = large.fraction(); - assert!(frac > 0.49 && frac < 0.51); - } - - #[tokio::test] - async fn test_collect_with_progress_cancel_cleans_up() { - use crate::ptp::{ - pack_u16, pack_u32, ContainerType, ObjectHandle, OperationCode, PtpSession, - ResponseCode, - }; - use crate::transport::mock::MockTransport; - use std::sync::Arc; - - // Helper to build a response container - fn response(tx_id: u32, code: ResponseCode) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(12)); - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf - } - - // Helper to build a data container - fn data(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { - let len = 12 + payload.len(); - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf.extend_from_slice(payload); - buf - } - - let mock = Arc::new(MockTransport::new()); - let transport: Arc = Arc::clone(&mock) as _; - mock.queue_response(response(0, ResponseCode::Ok)); // OpenSession - - let file_data = vec![1u8; 1000]; - let file_size = file_data.len() as u64; - mock.queue_response(data(1, OperationCode::GetObject, &file_data)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - let download = FileDownload::new(file_size, stream); - - // Break after first chunk - let result = download - .collect_with_progress(|_progress| ControlFlow::Break(())) - .await; - - assert!(matches!(result, Err(Error::Cancelled))); - - // Verify cancel_transfer was called with the correct transaction ID - let cancel_calls = mock.get_cancel_calls(); - assert_eq!(cancel_calls, vec![1]); - } -} diff --git a/vendor/mtp-rs/src/ptp/codes.rs b/vendor/mtp-rs/src/ptp/codes.rs deleted file mode 100644 index 96282e4..0000000 --- a/vendor/mtp-rs/src/ptp/codes.rs +++ /dev/null @@ -1,660 +0,0 @@ -//! MTP/PTP protocol operation, response, event, and format codes. -//! -//! This module defines the standard codes used in MTP/PTP communication: -//! - [`OperationCode`]: Commands sent to the device -//! - [`ResponseCode`]: Status codes returned by the device -//! - [`EventCode`]: Asynchronous events from the device -//! - [`ObjectFormatCode`]: File format identifiers - -use num_enum::{FromPrimitive, IntoPrimitive}; - -/// PTP operation codes (commands sent to device). -/// -/// These codes identify the operation being requested in a PTP command container. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum OperationCode { - /// Get device information (capabilities, manufacturer, etc.). - GetDeviceInfo = 0x1001, - /// Open a session with the device. - OpenSession = 0x1002, - /// Close the current session. - CloseSession = 0x1003, - /// Get list of storage IDs. - GetStorageIds = 0x1004, - /// Get information about a storage. - GetStorageInfo = 0x1005, - /// Get the number of objects in a storage/folder. - GetNumObjects = 0x1006, - /// Get list of object handles. - GetObjectHandles = 0x1007, - /// Get information about an object. - GetObjectInfo = 0x1008, - /// Download an object's data. - GetObject = 0x1009, - /// Get thumbnail for an object. - GetThumb = 0x100A, - /// Delete an object. - DeleteObject = 0x100B, - /// Send object metadata (before sending object data). - SendObjectInfo = 0x100C, - /// Send object data (after SendObjectInfo). - SendObject = 0x100D, - /// Initiate image capture on a camera. - InitiateCapture = 0x100E, - /// Get device property descriptor. - GetDevicePropDesc = 0x1014, - /// Get current device property value. - GetDevicePropValue = 0x1015, - /// Set device property value. - SetDevicePropValue = 0x1016, - /// Reset device property to default value. - ResetDevicePropValue = 0x1017, - /// Move an object to a different location. - MoveObject = 0x1019, - /// Copy an object. - CopyObject = 0x101A, - /// Get partial object data (range request). Offset is u32, so capped at 4 GB. - GetPartialObject = 0x101B, - /// Get the value of an object property (MTP extension). - GetObjectPropValue = 0x9803, - /// Set the value of an object property (MTP extension). - SetObjectPropValue = 0x9804, - /// Get partial object data with 64-bit offset (Android/MTP extension). - /// Supports files larger than 4 GB. Parameters: handle, offset_lo, offset_hi, max_bytes. - GetPartialObject64 = 0x95C1, - /// Unknown or vendor-specific operation code. - #[num_enum(catch_all)] - Unknown(u16), -} - -/// PTP response codes (status returned by device). -/// -/// These codes indicate the result of an operation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum ResponseCode { - /// Operation completed successfully. - Ok = 0x2001, - /// General unspecified error. - GeneralError = 0x2002, - /// Session is not open. - SessionNotOpen = 0x2003, - /// Invalid transaction ID. - InvalidTransactionId = 0x2004, - /// Operation is not supported. - OperationNotSupported = 0x2005, - /// Parameter is not supported. - ParameterNotSupported = 0x2006, - /// Transfer was incomplete. - IncompleteTransfer = 0x2007, - /// Invalid storage ID. - InvalidStorageId = 0x2008, - /// Invalid object handle. - InvalidObjectHandle = 0x2009, - /// Device property not supported. - DevicePropNotSupported = 0x200A, - /// Invalid object format code. - InvalidObjectFormatCode = 0x200B, - /// Storage is full. - StoreFull = 0x200C, - /// Object is write-protected. - ObjectWriteProtected = 0x200D, - /// Storage is read-only. - StoreReadOnly = 0x200E, - /// Access denied. - AccessDenied = 0x200F, - /// Object has no thumbnail. - NoThumbnailPresent = 0x2010, - /// Device is busy. - DeviceBusy = 0x2019, - /// Invalid parent object. - InvalidParentObject = 0x201A, - /// Invalid device property format. - InvalidDevicePropFormat = 0x201B, - /// Invalid device property value. - InvalidDevicePropValue = 0x201C, - /// Invalid parameter value. - InvalidParameter = 0x201D, - /// Session is already open. - SessionAlreadyOpen = 0x201E, - /// Transaction was cancelled. - TransactionCancelled = 0x201F, - /// Object is too large for the storage. - ObjectTooLarge = 0xA809, - /// Unknown or vendor-specific response code. - #[num_enum(catch_all)] - Unknown(u16), -} - -/// PTP event codes (asynchronous notifications from device). -/// -/// These codes identify events that the device sends asynchronously. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum EventCode { - /// Cancel an in-progress transaction. - CancelTransaction = 0x4001, - /// A new object was added. - ObjectAdded = 0x4002, - /// An object was removed. - ObjectRemoved = 0x4003, - /// A new storage was added. - StoreAdded = 0x4004, - /// A storage was removed. - StoreRemoved = 0x4005, - /// A device property changed. - DevicePropChanged = 0x4006, - /// Object information changed. - ObjectInfoChanged = 0x4007, - /// Device information changed. - DeviceInfoChanged = 0x4008, - /// Storage information changed. - StorageInfoChanged = 0x400C, - /// Capture operation completed. - CaptureComplete = 0x400D, - /// Unknown or vendor-specific event code. - #[num_enum(catch_all)] - Unknown(u16), -} - -/// PTP/MTP object format codes. -/// -/// These codes identify the format/type of objects stored on the device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum ObjectFormatCode { - /// Undefined/unknown format. - Undefined = 0x3000, - /// Association (folder/directory). - Association = 0x3001, - /// Script file. - Script = 0x3002, - /// Executable file. - Executable = 0x3003, - /// Plain text file. - Text = 0x3004, - /// HTML file. - Html = 0x3005, - /// DPOF (Digital Print Order Format). - Dpof = 0x3006, - /// AIFF audio. - Aiff = 0x3007, - /// WAV audio. - Wav = 0x3008, - /// MP3 audio. - Mp3 = 0x3009, - /// AVI video. - Avi = 0x300A, - /// MPEG video. - Mpeg = 0x300B, - /// ASF (Advanced Systems Format). - Asf = 0x300C, - /// JPEG image. - Jpeg = 0x3801, - /// TIFF image. - Tiff = 0x3804, - /// GIF image. - Gif = 0x3807, - /// BMP image. - Bmp = 0x3808, - /// PICT image. - Pict = 0x380A, - /// PNG image. - Png = 0x380B, - /// WMA audio. - WmaAudio = 0xB901, - /// OGG audio. - OggAudio = 0xB902, - /// AAC audio. - AacAudio = 0xB903, - /// FLAC audio. - FlacAudio = 0xB906, - /// WMV video. - WmvVideo = 0xB981, - /// MP4 container. - Mp4Container = 0xB982, - /// M4A audio. - M4aAudio = 0xB984, - /// Unknown or vendor-specific format code. - #[num_enum(catch_all)] - Unknown(u16), -} - -impl ObjectFormatCode { - /// Detect object format from file extension (case insensitive). - /// - /// Returns `Undefined` for unrecognized extensions. - #[must_use] - pub fn from_extension(ext: &str) -> Self { - match ext.to_lowercase().as_str() { - // Text and documents - "txt" => ObjectFormatCode::Text, - "html" | "htm" => ObjectFormatCode::Html, - "dpof" => ObjectFormatCode::Dpof, - - // Audio formats - "aiff" | "aif" => ObjectFormatCode::Aiff, - "wav" => ObjectFormatCode::Wav, - "mp3" => ObjectFormatCode::Mp3, - "wma" => ObjectFormatCode::WmaAudio, - "ogg" | "oga" => ObjectFormatCode::OggAudio, - "aac" => ObjectFormatCode::AacAudio, - "flac" => ObjectFormatCode::FlacAudio, - "m4a" => ObjectFormatCode::M4aAudio, - - // Video formats - "avi" => ObjectFormatCode::Avi, - "mpg" | "mpeg" => ObjectFormatCode::Mpeg, - "asf" => ObjectFormatCode::Asf, - "wmv" => ObjectFormatCode::WmvVideo, - "mp4" | "m4v" => ObjectFormatCode::Mp4Container, - - // Image formats - "jpg" | "jpeg" => ObjectFormatCode::Jpeg, - "tif" | "tiff" => ObjectFormatCode::Tiff, - "gif" => ObjectFormatCode::Gif, - "bmp" => ObjectFormatCode::Bmp, - "pict" | "pct" => ObjectFormatCode::Pict, - "png" => ObjectFormatCode::Png, - - // Executables and scripts - "exe" | "dll" | "bin" => ObjectFormatCode::Executable, - "sh" | "bat" | "cmd" | "ps1" => ObjectFormatCode::Script, - - _ => ObjectFormatCode::Undefined, - } - } - - /// Check if this format is an audio format. - #[must_use] - pub fn is_audio(&self) -> bool { - matches!( - self, - ObjectFormatCode::Aiff - | ObjectFormatCode::Wav - | ObjectFormatCode::Mp3 - | ObjectFormatCode::WmaAudio - | ObjectFormatCode::OggAudio - | ObjectFormatCode::AacAudio - | ObjectFormatCode::FlacAudio - | ObjectFormatCode::M4aAudio - ) - } - - /// Check if this format is a video format. - #[must_use] - pub fn is_video(&self) -> bool { - matches!( - self, - ObjectFormatCode::Avi - | ObjectFormatCode::Mpeg - | ObjectFormatCode::Asf - | ObjectFormatCode::WmvVideo - | ObjectFormatCode::Mp4Container - ) - } - - /// Check if this format is an image format. - #[must_use] - pub fn is_image(&self) -> bool { - matches!( - self, - ObjectFormatCode::Jpeg - | ObjectFormatCode::Tiff - | ObjectFormatCode::Gif - | ObjectFormatCode::Bmp - | ObjectFormatCode::Pict - | ObjectFormatCode::Png - ) - } -} - -// Manual impl required because #[default] attribute conflicts with num_enum's #[num_enum(catch_all)] -#[allow(clippy::derivable_impls)] -impl Default for ObjectFormatCode { - fn default() -> Self { - ObjectFormatCode::Undefined - } -} - -/// MTP object property codes. -/// -/// These codes identify object properties that can be get/set via MTP operations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum ObjectPropertyCode { - /// Storage ID containing the object. - StorageId = 0xDC01, - /// Object format code. - ObjectFormat = 0xDC02, - /// Protection status (read-only, etc.). - ProtectionStatus = 0xDC03, - /// Object size in bytes. - ObjectSize = 0xDC04, - /// Object filename (key property for renaming). - ObjectFileName = 0xDC07, - /// Date the object was created. - DateCreated = 0xDC08, - /// Date the object was last modified. - DateModified = 0xDC09, - /// Parent object handle. - ParentObject = 0xDC0B, - /// Display name of the object. - Name = 0xDC44, - /// Unknown or vendor-specific property code. - #[num_enum(catch_all)] - Unknown(u16), -} - -/// PTP property data type codes. -/// -/// These codes identify the data type of property values in property descriptors. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum PropertyDataType { - /// Undefined type (0x0000). - Undefined = 0x0000, - /// Signed 8-bit integer (0x0001). - Int8 = 0x0001, - /// Unsigned 8-bit integer (0x0002). - Uint8 = 0x0002, - /// Signed 16-bit integer (0x0003). - Int16 = 0x0003, - /// Unsigned 16-bit integer (0x0004). - Uint16 = 0x0004, - /// Signed 32-bit integer (0x0005). - Int32 = 0x0005, - /// Unsigned 32-bit integer (0x0006). - Uint32 = 0x0006, - /// Signed 64-bit integer (0x0007). - Int64 = 0x0007, - /// Unsigned 64-bit integer (0x0008). - Uint64 = 0x0008, - /// Signed 128-bit integer (0x0009, rarely used). - Int128 = 0x0009, - /// Unsigned 128-bit integer (0x000A, rarely used). - Uint128 = 0x000A, - /// Unknown type code. - #[num_enum(catch_all)] - Unknown(u16), - /// UTF-16LE string (0xFFFF). - String = 0xFFFF, -} - -impl PropertyDataType { - /// Returns the byte size of this data type. - /// - /// Returns `None` for variable-length types (String) and unsupported types - /// (Undefined, Int128, Uint128, Unknown). - #[must_use] - pub fn byte_size(&self) -> Option { - match self { - PropertyDataType::Int8 | PropertyDataType::Uint8 => Some(1), - PropertyDataType::Int16 | PropertyDataType::Uint16 => Some(2), - PropertyDataType::Int32 | PropertyDataType::Uint32 => Some(4), - PropertyDataType::Int64 | PropertyDataType::Uint64 => Some(8), - PropertyDataType::Int128 | PropertyDataType::Uint128 => Some(16), - PropertyDataType::String - | PropertyDataType::Undefined - | PropertyDataType::Unknown(_) => None, - } - } -} - -/// Standard PTP device property codes (0x5000 range). -/// -/// These codes identify device-level properties that can be read or modified -/// using the GetDevicePropDesc, GetDevicePropValue, SetDevicePropValue, and -/// ResetDevicePropValue operations. -/// -/// Device properties are primarily used with digital cameras for settings -/// like ISO, aperture, shutter speed, etc. Most Android MTP devices do not -/// support device properties. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum DevicePropertyCode { - /// Undefined property. - Undefined = 0x5000, - /// Battery level (UINT8, 0-100 percent). - BatteryLevel = 0x5001, - /// Device functional mode (UINT16). - FunctionalMode = 0x5002, - /// Image size setting (String, e.g., "1920x1080"). - ImageSize = 0x5003, - /// Compression setting (UINT8). - CompressionSetting = 0x5004, - /// White balance (UINT16). - WhiteBalance = 0x5005, - /// RGB gain (String). - RgbGain = 0x5006, - /// F-Number/Aperture (UINT16, value/100 = f-stop). - FNumber = 0x5007, - /// Focal length (UINT32, units of 0.01mm). - FocalLength = 0x5008, - /// Focus distance (UINT16, mm). - FocusDistance = 0x5009, - /// Focus mode (UINT16). - FocusMode = 0x500A, - /// Exposure metering mode (UINT16). - ExposureMeteringMode = 0x500B, - /// Flash mode (UINT16). - FlashMode = 0x500C, - /// Exposure time/shutter speed (UINT32, units of 0.0001s). - ExposureTime = 0x500D, - /// Exposure program mode (UINT16). - ExposureProgramMode = 0x500E, - /// Exposure index/ISO (UINT16). - ExposureIndex = 0x500F, - /// Exposure bias compensation (INT16, units of 0.001 EV). - ExposureBiasCompensation = 0x5010, - /// Date and time (String, "YYYYMMDDThhmmss"). - DateTime = 0x5011, - /// Capture delay (UINT32, ms). - CaptureDelay = 0x5012, - /// Still capture mode (UINT16). - StillCaptureMode = 0x5013, - /// Contrast (UINT8). - Contrast = 0x5014, - /// Sharpness (UINT8). - Sharpness = 0x5015, - /// Digital zoom (UINT8). - DigitalZoom = 0x5016, - /// Effect mode (UINT16). - EffectMode = 0x5017, - /// Burst number (UINT16). - BurstNumber = 0x5018, - /// Burst interval (UINT16, ms). - BurstInterval = 0x5019, - /// Timelapse number (UINT16). - TimelapseNumber = 0x501A, - /// Timelapse interval (UINT32, ms). - TimelapseInterval = 0x501B, - /// Focus metering mode (UINT16). - FocusMeteringMode = 0x501C, - /// Upload URL (String). - UploadUrl = 0x501D, - /// Artist name (String). - Artist = 0x501E, - /// Copyright info (String). - CopyrightInfo = 0x501F, - /// Unknown/vendor-specific property code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_extension_detection() { - // Audio (representative samples) - assert_eq!( - ObjectFormatCode::from_extension("mp3"), - ObjectFormatCode::Mp3 - ); - assert_eq!( - ObjectFormatCode::from_extension("flac"), - ObjectFormatCode::FlacAudio - ); - assert_eq!( - ObjectFormatCode::from_extension("aif"), - ObjectFormatCode::Aiff - ); // alternate ext - - // Video - assert_eq!( - ObjectFormatCode::from_extension("mp4"), - ObjectFormatCode::Mp4Container - ); - assert_eq!( - ObjectFormatCode::from_extension("avi"), - ObjectFormatCode::Avi - ); - assert_eq!( - ObjectFormatCode::from_extension("mpg"), - ObjectFormatCode::Mpeg - ); // alternate ext - - // Image - assert_eq!( - ObjectFormatCode::from_extension("jpg"), - ObjectFormatCode::Jpeg - ); - assert_eq!( - ObjectFormatCode::from_extension("png"), - ObjectFormatCode::Png - ); - assert_eq!( - ObjectFormatCode::from_extension("tif"), - ObjectFormatCode::Tiff - ); // alternate ext - - // Text/Documents - assert_eq!( - ObjectFormatCode::from_extension("txt"), - ObjectFormatCode::Text - ); - assert_eq!( - ObjectFormatCode::from_extension("htm"), - ObjectFormatCode::Html - ); // alternate ext - - // Executables/Scripts - assert_eq!( - ObjectFormatCode::from_extension("exe"), - ObjectFormatCode::Executable - ); - assert_eq!( - ObjectFormatCode::from_extension("sh"), - ObjectFormatCode::Script - ); - - // Case insensitivity (one example suffices since .to_lowercase() is used) - assert_eq!( - ObjectFormatCode::from_extension("MP3"), - ObjectFormatCode::Mp3 - ); - - // Unknown extensions - assert_eq!( - ObjectFormatCode::from_extension("xyz"), - ObjectFormatCode::Undefined - ); - assert_eq!( - ObjectFormatCode::from_extension(""), - ObjectFormatCode::Undefined - ); - } - - // ==================== Format Category Tests ==================== - - #[test] - fn is_audio() { - assert!(ObjectFormatCode::Mp3.is_audio()); - assert!(ObjectFormatCode::FlacAudio.is_audio()); - assert!(!ObjectFormatCode::Jpeg.is_audio()); - assert!(!ObjectFormatCode::Mp4Container.is_audio()); - } - - #[test] - fn is_video() { - assert!(ObjectFormatCode::Mp4Container.is_video()); - assert!(ObjectFormatCode::Avi.is_video()); - assert!(!ObjectFormatCode::Mp3.is_video()); - assert!(!ObjectFormatCode::Jpeg.is_video()); - } - - #[test] - fn is_image() { - assert!(ObjectFormatCode::Jpeg.is_image()); - assert!(ObjectFormatCode::Png.is_image()); - assert!(!ObjectFormatCode::Mp3.is_image()); - assert!(!ObjectFormatCode::Mp4Container.is_image()); - } - - #[test] - fn format_categories_are_mutually_exclusive() { - let all_formats = [ - ObjectFormatCode::Undefined, - ObjectFormatCode::Association, - ObjectFormatCode::Script, - ObjectFormatCode::Executable, - ObjectFormatCode::Text, - ObjectFormatCode::Html, - ObjectFormatCode::Dpof, - ObjectFormatCode::Aiff, - ObjectFormatCode::Wav, - ObjectFormatCode::Mp3, - ObjectFormatCode::Avi, - ObjectFormatCode::Mpeg, - ObjectFormatCode::Asf, - ObjectFormatCode::Jpeg, - ObjectFormatCode::Tiff, - ObjectFormatCode::Gif, - ObjectFormatCode::Bmp, - ObjectFormatCode::Pict, - ObjectFormatCode::Png, - ObjectFormatCode::WmaAudio, - ObjectFormatCode::OggAudio, - ObjectFormatCode::AacAudio, - ObjectFormatCode::FlacAudio, - ObjectFormatCode::WmvVideo, - ObjectFormatCode::Mp4Container, - ObjectFormatCode::M4aAudio, - ]; - - for format in all_formats { - let categories = [format.is_audio(), format.is_video(), format.is_image()]; - let true_count = categories.iter().filter(|&&b| b).count(); - assert!( - true_count <= 1, - "{:?} belongs to multiple categories", - format - ); - } - } - - // ==================== PropertyDataType Tests ==================== - - #[test] - fn property_data_type_byte_size() { - // Fixed-size types - assert_eq!(PropertyDataType::Int8.byte_size(), Some(1)); - assert_eq!(PropertyDataType::Uint8.byte_size(), Some(1)); - assert_eq!(PropertyDataType::Int16.byte_size(), Some(2)); - assert_eq!(PropertyDataType::Uint16.byte_size(), Some(2)); - assert_eq!(PropertyDataType::Int32.byte_size(), Some(4)); - assert_eq!(PropertyDataType::Uint32.byte_size(), Some(4)); - assert_eq!(PropertyDataType::Int64.byte_size(), Some(8)); - assert_eq!(PropertyDataType::Uint64.byte_size(), Some(8)); - assert_eq!(PropertyDataType::Int128.byte_size(), Some(16)); - assert_eq!(PropertyDataType::Uint128.byte_size(), Some(16)); - - // Variable/undefined types - assert_eq!(PropertyDataType::String.byte_size(), None); - assert_eq!(PropertyDataType::Undefined.byte_size(), None); - assert_eq!(PropertyDataType::Unknown(0x1234).byte_size(), None); - } -} diff --git a/vendor/mtp-rs/src/ptp/container.rs b/vendor/mtp-rs/src/ptp/container.rs deleted file mode 100644 index 3b4ac2f..0000000 --- a/vendor/mtp-rs/src/ptp/container.rs +++ /dev/null @@ -1,724 +0,0 @@ -//! MTP/PTP USB container format. -//! -//! This module implements the USB container format used for MTP/PTP communication. -//! All containers share a common 12-byte header followed by optional parameters or payload. -//! -//! ## Container format (little-endian) -//! -//! Header (12 bytes): -//! - Offset 0: Length (u32) - Total container size including header -//! - Offset 4: Type (u16) - Container type -//! - Offset 6: Code (u16) - Operation/Response/Event code -//! - Offset 8: TransactionID (u32) -//! -//! After header: parameters (each u32) or payload bytes. - -use super::{pack_u16, pack_u32, unpack_u16, unpack_u32, EventCode, OperationCode, ResponseCode}; - -/// Minimum container header size in bytes. -const HEADER_SIZE: usize = 12; - -/// Container type identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u16)] -pub enum ContainerType { - /// Command container (sent to device). - Command = 1, - /// Data container (bidirectional). - Data = 2, - /// Response container (from device). - Response = 3, - /// Event container (from device). - Event = 4, -} - -impl ContainerType { - /// Convert a raw u16 value to a ContainerType. - #[must_use] - pub fn from_code(code: u16) -> Option { - match code { - 1 => Some(ContainerType::Command), - 2 => Some(ContainerType::Data), - 3 => Some(ContainerType::Response), - 4 => Some(ContainerType::Event), - _ => None, - } - } - - /// Convert a ContainerType to its raw u16 value. - #[must_use] - pub fn to_code(self) -> u16 { - self as u16 - } -} - -/// Determine the container type from a raw buffer. -/// -/// Returns an error if the buffer is too small or contains an invalid container type. -pub fn container_type(buf: &[u8]) -> Result { - if buf.len() < HEADER_SIZE { - return Err(crate::Error::invalid_data(format!( - "container too small: need at least {} bytes, have {}", - HEADER_SIZE, - buf.len() - ))); - } - - let type_code = unpack_u16(&buf[4..6])?; - ContainerType::from_code(type_code) - .ok_or_else(|| crate::Error::invalid_data(format!("invalid container type: {}", type_code))) -} - -/// Command container sent to the device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommandContainer { - /// Operation code for the command. - pub code: OperationCode, - /// Transaction ID for this operation. - pub transaction_id: u32, - /// Parameters (0-5 u32 values). - pub params: Vec, -} - -impl CommandContainer { - /// Serialize the command container to bytes. - pub fn to_bytes(&self) -> Vec { - let param_bytes = self.params.len() * 4; - let total_len = HEADER_SIZE + param_bytes; - - let mut buf = Vec::with_capacity(total_len); - - // Header - buf.extend_from_slice(&pack_u32(total_len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Command.to_code())); - buf.extend_from_slice(&pack_u16(self.code.into())); - buf.extend_from_slice(&pack_u32(self.transaction_id)); - - // Parameters - for ¶m in &self.params { - buf.extend_from_slice(&pack_u32(param)); - } - - buf - } -} - -/// Data container for transferring payload data. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DataContainer { - /// Operation code this data belongs to. - pub code: OperationCode, - /// Transaction ID for this operation. - pub transaction_id: u32, - /// Payload bytes. - pub payload: Vec, -} - -impl DataContainer { - /// Serialize the data container to bytes. - pub fn to_bytes(&self) -> Vec { - let total_len = HEADER_SIZE + self.payload.len(); - - let mut buf = Vec::with_capacity(total_len); - - // Header - buf.extend_from_slice(&pack_u32(total_len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - buf.extend_from_slice(&pack_u16(self.code.into())); - buf.extend_from_slice(&pack_u32(self.transaction_id)); - - // Payload - buf.extend_from_slice(&self.payload); - - buf - } - - /// Parse a data container from bytes. - pub fn from_bytes(buf: &[u8]) -> Result { - if buf.len() < HEADER_SIZE { - return Err(crate::Error::invalid_data(format!( - "data container too small: need at least {} bytes, have {}", - HEADER_SIZE, - buf.len() - ))); - } - - let length = unpack_u32(&buf[0..4])? as usize; - let type_code = unpack_u16(&buf[4..6])?; - let code = unpack_u16(&buf[6..8])?; - let transaction_id = unpack_u32(&buf[8..12])?; - - // Validate container type - if type_code != ContainerType::Data.to_code() { - return Err(crate::Error::invalid_data(format!( - "expected Data container type ({}), got {}", - ContainerType::Data.to_code(), - type_code - ))); - } - - // Validate length - must be at least header size and not exceed buffer - if length < HEADER_SIZE { - return Err(crate::Error::invalid_data(format!( - "data container length too small: {} < header size {}", - length, HEADER_SIZE - ))); - } - if buf.len() < length { - return Err(crate::Error::invalid_data(format!( - "data container length mismatch: header says {}, have {}", - length, - buf.len() - ))); - } - - // Extract payload - let payload = buf[HEADER_SIZE..length].to_vec(); - - Ok(DataContainer { - code: code.into(), - transaction_id, - payload, - }) - } -} - -/// Response container from the device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResponseContainer { - /// Response code indicating success or failure. - pub code: ResponseCode, - /// Transaction ID this response corresponds to. - pub transaction_id: u32, - /// Response parameters (0-5 u32 values). - pub params: Vec, -} - -impl ResponseContainer { - /// Parse a response container from bytes. - pub fn from_bytes(buf: &[u8]) -> Result { - if buf.len() < HEADER_SIZE { - return Err(crate::Error::invalid_data(format!( - "response container too small: need at least {} bytes, have {}", - HEADER_SIZE, - buf.len() - ))); - } - - let length = unpack_u32(&buf[0..4])? as usize; - let type_code = unpack_u16(&buf[4..6])?; - let code = unpack_u16(&buf[6..8])?; - let transaction_id = unpack_u32(&buf[8..12])?; - - // Validate container type - if type_code != ContainerType::Response.to_code() { - return Err(crate::Error::invalid_data(format!( - "expected Response container type ({}), got {}", - ContainerType::Response.to_code(), - type_code - ))); - } - - // Validate length - if buf.len() < length { - return Err(crate::Error::invalid_data(format!( - "response container length mismatch: header says {}, have {}", - length, - buf.len() - ))); - } - - // Parse parameters - let param_bytes = length - HEADER_SIZE; - if param_bytes % 4 != 0 { - return Err(crate::Error::invalid_data(format!( - "response parameter bytes not aligned: {} bytes", - param_bytes - ))); - } - - let param_count = param_bytes / 4; - let mut params = Vec::with_capacity(param_count); - for i in 0..param_count { - let offset = HEADER_SIZE + i * 4; - params.push(unpack_u32(&buf[offset..])?); - } - - Ok(ResponseContainer { - code: code.into(), - transaction_id, - params, - }) - } - - /// Check if the response indicates success (Ok). - #[must_use] - pub fn is_ok(&self) -> bool { - self.code == ResponseCode::Ok - } -} - -/// Event container from the device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EventContainer { - /// Event code identifying the event type. - pub code: EventCode, - /// Transaction ID (may be 0 for unsolicited events). - pub transaction_id: u32, - /// Event parameters (always exactly 3). - pub params: [u32; 3], -} - -impl EventContainer { - /// Serialize the event container to bytes. - /// - /// Produces a 24-byte container: 12-byte header + 3 u32 parameters. - pub fn to_bytes(&self) -> Vec { - const EVENT_SIZE: usize = HEADER_SIZE + 12; // 3 params - let mut buf = Vec::with_capacity(EVENT_SIZE); - buf.extend_from_slice(&pack_u32(EVENT_SIZE as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Event.to_code())); - buf.extend_from_slice(&pack_u16(self.code.into())); - buf.extend_from_slice(&pack_u32(self.transaction_id)); - for ¶m in &self.params { - buf.extend_from_slice(&pack_u32(param)); - } - buf - } - - /// Parse an event container from bytes. - /// - /// Events can have 0-3 parameters, so valid sizes are 12-24 bytes - /// (header + 0-3 u32 params). Missing parameters default to 0. - pub fn from_bytes(buf: &[u8]) -> Result { - const MAX_EVENT_SIZE: usize = HEADER_SIZE + 12; // 24 bytes max (3 params) - - if buf.len() < HEADER_SIZE { - return Err(crate::Error::invalid_data(format!( - "event container too small: need at least {} bytes, have {}", - HEADER_SIZE, - buf.len() - ))); - } - - let length = unpack_u32(&buf[0..4])? as usize; - let type_code = unpack_u16(&buf[4..6])?; - let code = unpack_u16(&buf[6..8])?; - let transaction_id = unpack_u32(&buf[8..12])?; - - // Validate container type - if type_code != ContainerType::Event.to_code() { - return Err(crate::Error::invalid_data(format!( - "expected Event container type ({}), got {}", - ContainerType::Event.to_code(), - type_code - ))); - } - - // Validate length: must be between 12 (header only) and 24 (header + 3 params) - if !(HEADER_SIZE..=MAX_EVENT_SIZE).contains(&length) { - return Err(crate::Error::invalid_data(format!( - "event container invalid size: expected 12-24, got {}", - length - ))); - } - - // Validate parameter alignment (must be multiple of 4 bytes after header) - let param_bytes = length - HEADER_SIZE; - if param_bytes % 4 != 0 { - return Err(crate::Error::invalid_data(format!( - "event parameter bytes not aligned: {} bytes", - param_bytes - ))); - } - - // Validate buffer has enough data - if buf.len() < length { - return Err(crate::Error::invalid_data(format!( - "event container buffer too small: need {}, have {}", - length, - buf.len() - ))); - } - - // Parse parameters (0-3), defaulting missing ones to 0 - let param_count = param_bytes / 4; - let param1 = if param_count >= 1 { - unpack_u32(&buf[12..16])? - } else { - 0 - }; - let param2 = if param_count >= 2 { - unpack_u32(&buf[16..20])? - } else { - 0 - }; - let param3 = if param_count >= 3 { - unpack_u32(&buf[20..24])? - } else { - 0 - }; - - Ok(EventContainer { - code: code.into(), - transaction_id, - params: [param1, param2, param3], - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - // --- ContainerType tests --- - - #[test] - fn container_type_conversions() { - for (code, ct) in [ - (1, ContainerType::Command), - (2, ContainerType::Data), - (3, ContainerType::Response), - (4, ContainerType::Event), - ] { - assert_eq!(ContainerType::from_code(code), Some(ct)); - assert_eq!(ct.to_code(), code); - } - for invalid in [0, 5, 0xFFFF] { - assert_eq!(ContainerType::from_code(invalid), None); - } - } - - #[test] - fn container_type_detection() { - // Build minimal containers and verify type detection - let containers: [(u16, ContainerType); 4] = [ - (1, ContainerType::Command), - (2, ContainerType::Data), - (3, ContainerType::Response), - (4, ContainerType::Event), - ]; - for (type_code, expected) in containers { - let mut bytes = vec![0x0C, 0x00, 0x00, 0x00]; // length = 12 - bytes.extend_from_slice(&type_code.to_le_bytes()); - bytes.extend_from_slice(&[0x00; 6]); // code + tx_id - assert_eq!(container_type(&bytes).unwrap(), expected); - } - - // Invalid type codes - for invalid in [0u16, 5] { - let mut bytes = vec![0x0C, 0x00, 0x00, 0x00]; - bytes.extend_from_slice(&invalid.to_le_bytes()); - bytes.extend_from_slice(&[0x00; 6]); - assert!(container_type(&bytes).is_err()); - } - - // Insufficient bytes - assert!(container_type(&[]).is_err()); - assert!(container_type(&[0x00; 11]).is_err()); - } - - // --- CommandContainer tests --- - - #[test] - fn command_container_serialization() { - let cmd = CommandContainer { - code: OperationCode::GetObjectHandles, - transaction_id: 10, - params: vec![0x00010001, 0x00000000, 0xFFFFFFFF], - }; - let bytes = cmd.to_bytes(); - assert_eq!(bytes.len(), 24); - assert_eq!(&bytes[0..4], &[0x18, 0x00, 0x00, 0x00]); // length = 24 - assert_eq!(&bytes[4..6], &[0x01, 0x00]); // type = Command - assert_eq!(&bytes[6..8], &[0x07, 0x10]); // code = 0x1007 - assert_eq!(&bytes[8..12], &[0x0A, 0x00, 0x00, 0x00]); // tx_id = 10 - assert_eq!(&bytes[12..16], &[0x01, 0x00, 0x01, 0x00]); // param1 - } - - // --- DataContainer tests --- - - #[test] - fn data_container_roundtrip() { - let original = DataContainer { - code: OperationCode::GetObject, - transaction_id: 100, - payload: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - }; - let parsed = DataContainer::from_bytes(&original.to_bytes()).unwrap(); - assert_eq!(parsed, original); - - // Empty payload - let empty = DataContainer { - code: OperationCode::SendObject, - transaction_id: 5, - payload: vec![], - }; - assert_eq!(DataContainer::from_bytes(&empty.to_bytes()).unwrap(), empty); - } - - #[test] - fn data_container_errors() { - assert!(DataContainer::from_bytes(&[0x00; 11]).is_err()); // Too small - - // Wrong type - let mut bad_type = vec![0x0C, 0x00, 0x00, 0x00, 0x03, 0x00]; // Response type - bad_type.extend_from_slice(&[0x00; 6]); - assert!(DataContainer::from_bytes(&bad_type).is_err()); - - // Length > buffer - let mut truncated = vec![0x20, 0x00, 0x00, 0x00, 0x02, 0x00]; // claims 32 bytes - truncated.extend_from_slice(&[0x00; 6]); - assert!(DataContainer::from_bytes(&truncated).is_err()); - } - - // --- ResponseContainer tests --- - - #[test] - fn response_container_parsing() { - // OK response with params - let bytes = [ - 0x18, 0x00, 0x00, 0x00, // length = 24 - 0x03, 0x00, // type = Response - 0x01, 0x20, // code = OK - 0x02, 0x00, 0x00, 0x00, // tx_id = 2 - 0x01, 0x00, 0x01, 0x00, // param1 - 0x00, 0x00, 0x00, 0x00, // param2 - 0x05, 0x00, 0x00, 0x00, // param3 - ]; - let resp = ResponseContainer::from_bytes(&bytes).unwrap(); - assert_eq!(resp.code, ResponseCode::Ok); - assert!(resp.is_ok()); - assert_eq!(resp.params, vec![0x00010001, 0, 5]); - - // Error response - let err_bytes = [ - 0x0C, 0x00, 0x00, 0x00, 0x03, 0x00, 0x02, 0x20, // GeneralError - 0x03, 0x00, 0x00, 0x00, - ]; - let err_resp = ResponseContainer::from_bytes(&err_bytes).unwrap(); - assert_eq!(err_resp.code, ResponseCode::GeneralError); - assert!(!err_resp.is_ok()); - } - - #[test] - fn response_container_errors() { - assert!(ResponseContainer::from_bytes(&[0x00; 11]).is_err()); - - // Unaligned params (13 bytes = 12 header + 1) - let unaligned = [ - 0x0D, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x20, 0x01, 0x00, 0x00, 0x00, 0xFF, - ]; - assert!(ResponseContainer::from_bytes(&unaligned).is_err()); - } - - // --- EventContainer tests --- - - #[test] - fn event_container_to_bytes_roundtrip() { - let original = EventContainer { - code: EventCode::CancelTransaction, - transaction_id: 42, - params: [42, 0, 0], - }; - let bytes = original.to_bytes(); - assert_eq!(bytes.len(), 24); - let parsed = EventContainer::from_bytes(&bytes).unwrap(); - assert_eq!(parsed, original); - } - - #[test] - fn event_container_variable_params() { - // 0 params (12 bytes) - let zero = [ - 0x0C, 0x00, 0x00, 0x00, 0x04, 0x00, 0x08, 0x40, 0x00, 0x00, 0x00, 0x00, - ]; - let e0 = EventContainer::from_bytes(&zero).unwrap(); - assert_eq!(e0.code, EventCode::DeviceInfoChanged); - assert_eq!(e0.params, [0, 0, 0]); - - // 1 param (16 bytes) - common on Android - let one = [ - 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x2A, 0x00, - 0x00, 0x00, - ]; - let e1 = EventContainer::from_bytes(&one).unwrap(); - assert_eq!(e1.params, [42, 0, 0]); - - // 3 params (24 bytes) - let three = [ - 0x18, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x0A, 0x00, 0x00, 0x00, 0x01, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, - ]; - let e3 = EventContainer::from_bytes(&three).unwrap(); - assert_eq!(e3.transaction_id, 10); - assert_eq!(e3.params, [1, 2, 3]); - } - - #[test] - fn event_container_errors() { - assert!(EventContainer::from_bytes(&[0x00; 11]).is_err()); - - // Length > 24 (too many params) - let too_long = [ - 0x1C, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; - assert!(EventContainer::from_bytes(&too_long).is_err()); - - // Unaligned (14 bytes) - let unaligned = [ - 0x0E, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; - assert!(EventContainer::from_bytes(&unaligned).is_err()); - } - - // --- Property-based tests --- - - fn valid_response_bytes(param_count: usize) -> impl Strategy> { - ( - any::(), - any::(), - prop::collection::vec(any::(), param_count..=param_count), - ) - .prop_map(move |(code, tx_id, params)| { - let len = HEADER_SIZE + params.len() * 4; - let mut bytes = Vec::with_capacity(len); - bytes.extend_from_slice(&pack_u32(len as u32)); - bytes.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - bytes.extend_from_slice(&pack_u16(code)); - bytes.extend_from_slice(&pack_u32(tx_id)); - for p in ¶ms { - bytes.extend_from_slice(&pack_u32(*p)); - } - bytes - }) - } - - proptest! { - #[test] - fn prop_container_type_roundtrip(code in 1u16..=4u16) { - let ct = ContainerType::from_code(code).unwrap(); - prop_assert_eq!(ct.to_code(), code); - } - - #[test] - fn prop_data_container_roundtrip( - code in any::(), - tx_id in any::(), - payload in prop::collection::vec(any::(), 0..500) - ) { - let original = DataContainer { - code: code.into(), - transaction_id: tx_id, - payload: payload.clone(), - }; - let parsed = DataContainer::from_bytes(&original.to_bytes()).unwrap(); - prop_assert_eq!(parsed, original); - } - - #[test] - fn prop_command_container_length( - code in any::(), - tx_id in any::(), - params in prop::collection::vec(any::(), 0..5) - ) { - let cmd = CommandContainer { - code: code.into(), - transaction_id: tx_id, - params: params.clone(), - }; - let bytes = cmd.to_bytes(); - let length = unpack_u32(&bytes[0..4]).unwrap() as usize; - prop_assert_eq!(length, HEADER_SIZE + params.len() * 4); - prop_assert_eq!(length, bytes.len()); - } - - #[test] - fn prop_response_container_parse(param_count in 0usize..=5usize) { - let strategy = valid_response_bytes(param_count); - proptest!(|(bytes in strategy)| { - let resp = ResponseContainer::from_bytes(&bytes).unwrap(); - prop_assert_eq!(resp.params.len(), param_count); - }); - } - - #[test] - fn prop_container_type_identification( - code in any::(), - tx_id in any::(), - payload in prop::collection::vec(any::(), 0..50) - ) { - let data = DataContainer { - code: code.into(), - transaction_id: tx_id, - payload, - }; - prop_assert_eq!(container_type(&data.to_bytes()).unwrap(), ContainerType::Data); - - let cmd = CommandContainer { - code: code.into(), - transaction_id: tx_id, - params: vec![], - }; - prop_assert_eq!(container_type(&cmd.to_bytes()).unwrap(), ContainerType::Command); - } - - // Adversarial tests - - #[test] - fn fuzz_data_container_length_underflow(fake_length in 0u32..12u32, tx_id: u32) { - let mut buf = fake_length.to_le_bytes().to_vec(); - buf.extend_from_slice(&2u16.to_le_bytes()); - buf.extend_from_slice(&0x1001u16.to_le_bytes()); - buf.extend_from_slice(&tx_id.to_le_bytes()); - prop_assert!(DataContainer::from_bytes(&buf).is_err()); - } - - #[test] - fn fuzz_event_container_invalid_length( - fake_length in prop::sample::select(vec![ - 0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // Too small - 13, 14, 15, 17, 18, 19, 21, 22, 23, // Unaligned - 25, 26, 28, 32, 100, // Too large - ]), - tx_id: u32, - ) { - let mut buf = fake_length.to_le_bytes().to_vec(); - buf.extend_from_slice(&4u16.to_le_bytes()); - buf.extend_from_slice(&0x4002u16.to_le_bytes()); - buf.extend_from_slice(&tx_id.to_le_bytes()); - buf.extend_from_slice(&[0u8; 12]); // 3 params - prop_assert!(EventContainer::from_bytes(&buf).is_err()); - } - - #[test] - fn fuzz_wrong_container_type( - tx_id: u32, - payload in prop::collection::vec(any::(), 0..20), - ) { - let len = 12 + payload.len(); - for (parser_type, wrong_type) in [(2u16, 1u16), (2, 3), (2, 4), (3, 1), (3, 2), (4, 1)] { - let mut buf = (len as u32).to_le_bytes().to_vec(); - buf.extend_from_slice(&wrong_type.to_le_bytes()); - buf.extend_from_slice(&0x1001u16.to_le_bytes()); - buf.extend_from_slice(&tx_id.to_le_bytes()); - buf.extend_from_slice(&payload); - - match parser_type { - 2 => prop_assert!(DataContainer::from_bytes(&buf).is_err()), - 3 => prop_assert!(ResponseContainer::from_bytes(&buf).is_err()), - 4 => prop_assert!(EventContainer::from_bytes(&buf).is_err()), - _ => {} - } - } - } - } - - // Fuzz tests - verify parsers don't panic on arbitrary input - crate::fuzz_bytes_fn!(fuzz_container_type, container_type, 100); - crate::fuzz_bytes!(fuzz_data_container, DataContainer, 100); - crate::fuzz_bytes!(fuzz_response_container, ResponseContainer, 100); - crate::fuzz_bytes!(fuzz_event_container, EventContainer, 100); -} diff --git a/vendor/mtp-rs/src/ptp/device.rs b/vendor/mtp-rs/src/ptp/device.rs deleted file mode 100644 index b022908..0000000 --- a/vendor/mtp-rs/src/ptp/device.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Low-level PTP device API. - -use crate::ptp::{ - container_type, unpack_u32, CommandContainer, ContainerType, DataContainer, DeviceInfo, - OperationCode, PtpSession, ResponseCode, ResponseContainer, -}; -use crate::transport::{NusbTransport, Transport}; -use crate::Error; -use std::sync::Arc; -use std::time::Duration; - -/// A low-level PTP device connection. -/// -/// Use this for camera support or when you need raw PTP operations. -/// For typical MTP usage with Android devices, prefer `MtpDevice` instead. -pub struct PtpDevice { - transport: Arc, -} - -impl PtpDevice { - /// Open a PTP device at a specific USB location (port). - pub async fn open_by_location(location_id: u64) -> Result { - Self::open_by_location_with_timeout(location_id, NusbTransport::DEFAULT_TIMEOUT).await - } - - /// Open by location with custom timeout. - pub async fn open_by_location_with_timeout( - location_id: u64, - timeout: Duration, - ) -> Result { - let devices = NusbTransport::list_mtp_devices()?; - let device_info = devices - .into_iter() - .find(|d| d.location_id == location_id) - .ok_or(Error::NoDevice)?; - Self::open_device(device_info, timeout).await - } - - /// Open a PTP device by its serial number. - pub async fn open_by_serial(serial: &str) -> Result { - Self::open_by_serial_with_timeout(serial, NusbTransport::DEFAULT_TIMEOUT).await - } - - /// Open by serial with custom timeout. - pub async fn open_by_serial_with_timeout( - serial: &str, - timeout: Duration, - ) -> Result { - let devices = NusbTransport::list_mtp_devices()?; - let device_info = devices - .into_iter() - .find(|d| d.serial_number.as_deref() == Some(serial)) - .ok_or(Error::NoDevice)?; - Self::open_device(device_info, timeout).await - } - - /// Open the first available PTP device. - pub async fn open_first() -> Result { - Self::open_first_with_timeout(NusbTransport::DEFAULT_TIMEOUT).await - } - - /// Open the first available device with custom timeout. - pub async fn open_first_with_timeout(timeout: Duration) -> Result { - let devices = NusbTransport::list_mtp_devices()?; - let device_info = devices.into_iter().next().ok_or(Error::NoDevice)?; - Self::open_device(device_info, timeout).await - } - - async fn open_device( - device_info: crate::transport::UsbDeviceInfo, - timeout: Duration, - ) -> Result { - let device = device_info.open().map_err(Error::Usb)?; - let transport = NusbTransport::open_with_timeout(device, timeout).await?; - Ok(Self { - transport: Arc::new(transport) as Arc, - }) - } - - /// Get device info without opening a session. - /// - /// This is the only operation that can be performed without a session. - pub async fn get_device_info(&self) -> Result { - // Build GetDeviceInfo command (transaction ID 0 for session-less) - let cmd = CommandContainer { - code: OperationCode::GetDeviceInfo, - transaction_id: 0, - params: vec![], - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - // Receive data - // A single PTP container may span multiple USB bulk transfers when the - // device sends the 12-byte header in one transfer and the payload in - // follow-up transfers (allowed by the PTP spec, observed on Garmin - // Forerunner 955). Accumulate transfers until the full container is in - // hand, matching the in-session receive logic. - let mut data_payload = Vec::new(); - loop { - let mut bytes = self.transport.receive_bulk(64 * 1024).await?; - if bytes.is_empty() { - return Err(Error::invalid_data("Empty response")); - } - - let ct = container_type(&bytes)?; - match ct { - ContainerType::Data => { - if bytes.len() >= 4 { - let total_length = unpack_u32(&bytes[0..4])? as usize; - while bytes.len() < total_length { - let more = self.transport.receive_bulk(64 * 1024).await?; - if more.is_empty() { - return Err(Error::invalid_data( - "Incomplete data container: device stopped sending", - )); - } - bytes.extend_from_slice(&more); - } - } - let container = DataContainer::from_bytes(&bytes)?; - data_payload.extend_from_slice(&container.payload); - } - ContainerType::Response => { - let response = ResponseContainer::from_bytes(&bytes)?; - if response.code != ResponseCode::Ok { - return Err(Error::Protocol { - code: response.code, - operation: OperationCode::GetDeviceInfo, - }); - } - break; - } - _ => { - return Err(Error::invalid_data(format!( - "Unexpected container type: {:?}", - ct - ))); - } - } - } - - DeviceInfo::from_bytes(&data_payload) - } - - /// Open a PTP session. - /// - /// Most operations require a session to be open first. - pub async fn open_session(&self) -> Result { - self.open_session_with_id(1).await - } - - /// Open a session with a specific session ID. - pub async fn open_session_with_id(&self, session_id: u32) -> Result { - PtpSession::open(Arc::clone(&self.transport), session_id).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::pack::{pack_u16, pack_u16_array, pack_u32}; - use crate::transport::mock::MockTransport; - - #[tokio::test] - #[ignore] // Requires real device - async fn test_open_first() { - let device = PtpDevice::open_first().await.unwrap(); - let info = device.get_device_info().await.unwrap(); - println!("Model: {}", info.model); - } - - #[tokio::test] - #[ignore] // Requires real device - async fn test_open_session() { - let device = PtpDevice::open_first().await.unwrap(); - let session = device.open_session().await.unwrap(); - - let info = session.get_device_info().await.unwrap(); - println!("Model: {}", info.model); - - session.close().await.unwrap(); - } - - /// Build a minimal valid DeviceInfo payload (no header). - fn minimal_device_info_payload() -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&pack_u16(100)); // StandardVersion - buf.extend_from_slice(&pack_u32(0)); // VendorExtensionID - buf.extend_from_slice(&pack_u16(0)); // VendorExtensionVersion - buf.push(0x00); // VendorExtensionDesc (empty string) - buf.extend_from_slice(&pack_u16(0)); // FunctionalMode - buf.extend_from_slice(&pack_u16_array(&[])); // OperationsSupported - buf.extend_from_slice(&pack_u16_array(&[])); // EventsSupported - buf.extend_from_slice(&pack_u16_array(&[])); // DevicePropertiesSupported - buf.extend_from_slice(&pack_u16_array(&[])); // CaptureFormats - buf.extend_from_slice(&pack_u16_array(&[])); // PlaybackFormats - buf.push(0x00); // Manufacturer (empty) - buf.push(0x00); // Model (empty) - buf.push(0x00); // DeviceVersion (empty) - buf.push(0x00); // SerialNumber (empty) - buf - } - - /// Build a 12-byte data container header for GetDeviceInfo. - fn data_container_header(total_length: u32) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(total_length)); - buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - buf.extend_from_slice(&pack_u16(OperationCode::GetDeviceInfo.into())); - buf.extend_from_slice(&pack_u32(0)); // tx_id 0 (session-less) - buf - } - - /// Build a 12-byte OK response for GetDeviceInfo (tx_id 0). - fn ok_response_session_less() -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(12)); - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); - buf.extend_from_slice(&pack_u32(0)); - buf - } - - #[tokio::test] - async fn get_device_info_handles_split_header_and_data() { - // Regression test: some devices (Garmin Forerunner 955, observed) send - // the 12-byte container header in one bulk transfer and the payload in - // a follow-up transfer. The session-less GetDeviceInfo path must - // accumulate transfers until the full container is received. - let mock = Arc::new(MockTransport::new()); - - let payload = minimal_device_info_payload(); - let total_length = 12 + payload.len() as u32; - - // Split: header in one transfer, payload in another. - mock.queue_response(data_container_header(total_length)); - mock.queue_response(payload); - mock.queue_response(ok_response_session_less()); - - let device = PtpDevice { - transport: mock as Arc, - }; - - let info = device.get_device_info().await.unwrap(); - assert_eq!(info.standard_version, 100); - } - - #[tokio::test] - async fn get_device_info_handles_combined_header_and_data() { - // Happy path: device sends header and payload as a single bulk transfer. - let mock = Arc::new(MockTransport::new()); - - let payload = minimal_device_info_payload(); - let total_length = 12 + payload.len() as u32; - - let mut combined = data_container_header(total_length); - combined.extend_from_slice(&payload); - mock.queue_response(combined); - mock.queue_response(ok_response_session_less()); - - let device = PtpDevice { - transport: mock as Arc, - }; - - let info = device.get_device_info().await.unwrap(); - assert_eq!(info.standard_version, 100); - } -} diff --git a/vendor/mtp-rs/src/ptp/mod.rs b/vendor/mtp-rs/src/ptp/mod.rs deleted file mode 100644 index 3f23348..0000000 --- a/vendor/mtp-rs/src/ptp/mod.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Low-level PTP (Picture Transfer Protocol) implementation. -//! -//! This module provides direct access to the PTP/MTP protocol layer. Use this module when: -//! -//! - Working with digital cameras that use PTP -//! - You need fine-grained control over protocol operations -//! - Implementing custom MTP extensions or vendor operations -//! - You need access to raw response codes for error handling -//! - Building your own high-level abstractions -//! -//! ## When to use `mtp` instead -//! -//! Most users working with Android devices should prefer the high-level [`crate::mtp`] module, -//! which provides a simpler API for common operations like listing files, uploading, and -//! downloading. -//! -//! ## Module structure -//! -//! - `codes`: Operation, response, event, and format code enums -//! - `pack`: Binary serialization/deserialization primitives -//! - `container`: USB container format for PTP messages -//! - `types`: DeviceInfo, StorageInfo, ObjectInfo structures -//! - `session`: PTP session management -//! - `device`: PtpDevice public API -//! -//! ## Example -//! -//! ```rust,no_run -//! use mtp_rs::ptp::PtpDevice; -//! -//! # async fn example() -> Result<(), mtp_rs::Error> { -//! // Open device and start a session -//! let device = PtpDevice::open_first().await?; -//! let session = device.open_session().await?; -//! -//! // Get device info -//! let info = session.get_device_info().await?; -//! println!("Model: {}", info.model); -//! -//! // List storage IDs -//! let storage_ids = session.get_storage_ids().await?; -//! # Ok(()) -//! # } -//! ``` - -mod codes; -mod container; -mod device; -mod pack; -mod session; -#[cfg(test)] -mod test_utils; -mod types; - -pub use codes::{ - DevicePropertyCode, EventCode, ObjectFormatCode, ObjectPropertyCode, OperationCode, - PropertyDataType, ResponseCode, -}; -pub use container::{ - container_type, CommandContainer, ContainerType, DataContainer, EventContainer, - ResponseContainer, -}; -pub use device::PtpDevice; -pub use pack::{ - pack_datetime, pack_i16, pack_i32, pack_i64, pack_i8, pack_string, pack_u16, pack_u16_array, - pack_u32, pack_u32_array, pack_u64, pack_u8, unpack_datetime, unpack_i16, unpack_i32, - unpack_i64, unpack_i8, unpack_string, unpack_u16, unpack_u16_array, unpack_u32, - unpack_u32_array, unpack_u64, unpack_u8, DateTime, -}; -pub use session::{receive_stream_to_stream, PtpSession, ReceiveStream}; -pub use types::{ - AccessCapability, AssociationType, DeviceInfo, DevicePropDesc, FilesystemType, ObjectInfo, - PropertyFormType, PropertyRange, PropertyValue, ProtectionStatus, StorageInfo, StorageType, -}; - -/// 32-bit object handle assigned by the device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct ObjectHandle(pub u32); - -impl ObjectHandle { - /// Root folder (parent = root means object is in storage root). - pub const ROOT: Self = ObjectHandle(0x00000000); - /// All objects (used in GetObjectHandles to list recursively). - pub const ALL: Self = ObjectHandle(0xFFFFFFFF); -} - -/// 32-bit storage identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct StorageId(pub u32); - -impl StorageId { - /// All storages (used in GetObjectHandles to search all). - pub const ALL: Self = StorageId(0xFFFFFFFF); -} - -/// 32-bit session identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct SessionId(pub u32); - -/// 32-bit transaction identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct TransactionId(pub u32); - -impl TransactionId { - /// The first valid transaction ID in a session. - pub const FIRST: Self = TransactionId(0x00000001); - - /// Invalid transaction ID (must never be used). - pub const INVALID: Self = TransactionId(0xFFFFFFFF); - - /// Transaction ID for session-less operations (e.g., GetDeviceInfo before OpenSession). - pub const SESSION_LESS: Self = TransactionId(0x00000000); - - /// Get the next transaction ID, wrapping correctly. - /// - /// Wraps from 0xFFFFFFFE to 0x00000001 (skipping both 0x00000000 and 0xFFFFFFFF). - #[must_use] - pub fn next(self) -> Self { - let next = self.0.wrapping_add(1); - if next == 0 || next == 0xFFFFFFFF { - TransactionId(0x00000001) - } else { - TransactionId(next) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn transaction_id_next() { - assert_eq!(TransactionId(1).next(), TransactionId(2)); - assert_eq!(TransactionId(100).next(), TransactionId(101)); - } - - #[test] - fn transaction_id_wrapping() { - // Should wrap from 0xFFFFFFFE to 0x00000001, skipping 0xFFFFFFFF and 0x00000000 - assert_eq!(TransactionId(0xFFFFFFFE).next(), TransactionId(1)); - assert_eq!(TransactionId(0xFFFFFFFD).next(), TransactionId(0xFFFFFFFE)); - } - - #[test] - fn object_handle_constants() { - assert_eq!(ObjectHandle::ROOT.0, 0); - assert_eq!(ObjectHandle::ALL.0, 0xFFFFFFFF); - } - - #[test] - fn storage_id_constants() { - assert_eq!(StorageId::ALL.0, 0xFFFFFFFF); - } -} diff --git a/vendor/mtp-rs/src/ptp/pack/datetime.rs b/vendor/mtp-rs/src/ptp/pack/datetime.rs deleted file mode 100644 index 4c8bd63..0000000 --- a/vendor/mtp-rs/src/ptp/pack/datetime.rs +++ /dev/null @@ -1,549 +0,0 @@ -//! DateTime struct and serialization for MTP/PTP. - -use super::{pack_string, unpack_string}; - -/// Date and time structure for MTP/PTP. -/// -/// Format: "YYYYMMDDThhmmss" (ISO 8601 subset) -/// -/// # Validation -/// -/// DateTime values must satisfy these constraints: -/// - Year: 0-9999 (4-digit representation) -/// - Month: 1-12 -/// - Day: 1-31 -/// - Hour: 0-23 -/// - Minute: 0-59 -/// - Second: 0-59 -/// -/// Use [`DateTime::new()`] to create validated instances, or [`DateTime::is_valid()`] -/// to check existing instances. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct DateTime { - /// Year (0-9999) - pub year: u16, - /// Month (1-12) - pub month: u8, - /// Day (1-31) - pub day: u8, - /// Hour (0-23) - pub hour: u8, - /// Minute (0-59) - pub minute: u8, - /// Second (0-59) - pub second: u8, -} - -impl DateTime { - /// Create a new DateTime with validation. - /// - /// Returns `None` if any value is out of range. - /// - /// # Example - /// - /// ``` - /// use mtp_rs::ptp::DateTime; - /// - /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); - /// assert_eq!(dt.year, 2024); - /// - /// // Invalid values return None - /// assert!(DateTime::new(2024, 13, 1, 0, 0, 0).is_none()); // month > 12 - /// assert!(DateTime::new(2024, 1, 1, 0, 60, 0).is_none()); // minute > 59 - /// ``` - #[must_use] - pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option { - let dt = DateTime { - year, - month, - day, - hour, - minute, - second, - }; - if dt.is_valid() { - Some(dt) - } else { - None - } - } - - /// Check if this DateTime has valid values. - /// - /// Returns `true` if all fields are within valid ranges: - /// - Year: 0-9999 - /// - Month: 1-12 - /// - Day: 1-31 - /// - Hour: 0-23 - /// - Minute: 0-59 - /// - Second: 0-59 - /// - /// Note: This does not validate day-of-month against the specific month - /// (e.g., Feb 31 would pass). MTP devices generally accept any 1-31 value. - #[must_use] - pub fn is_valid(&self) -> bool { - self.year <= 9999 - && (1..=12).contains(&self.month) - && (1..=31).contains(&self.day) - && self.hour <= 23 - && self.minute <= 59 - && self.second <= 59 - } - - /// Parse a datetime string in MTP format. - /// - /// Format: "YYYYMMDDThhmmss" with optional timezone suffix (Z or +hhmm/-hhmm). - /// The timezone suffix is parsed but ignored. - /// - /// Returns `None` if the string is malformed or contains invalid values. - #[must_use] - pub fn parse(s: &str) -> Option { - // Minimum length: "YYYYMMDDThhmmss" = 15 characters - if s.len() < 15 { - return None; - } - - // Check for 'T' separator at position 8 - if s.as_bytes().get(8) != Some(&b'T') { - return None; - } - - // Parse components - let year: u16 = s.get(0..4)?.parse().ok()?; - let month: u8 = s.get(4..6)?.parse().ok()?; - let day: u8 = s.get(6..8)?.parse().ok()?; - let hour: u8 = s.get(9..11)?.parse().ok()?; - let minute: u8 = s.get(11..13)?.parse().ok()?; - let second: u8 = s.get(13..15)?.parse().ok()?; - - // Use new() which validates - Self::new(year, month, day, hour, minute, second) - } - - /// Format the datetime as an MTP string. - /// - /// Returns `Some("YYYYMMDDThhmmss")` if the values are valid (exactly 15 characters), - /// or `None` if any value is out of range. - /// - /// # Example - /// - /// ``` - /// use mtp_rs::ptp::DateTime; - /// - /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); - /// assert_eq!(dt.format(), Some("20240315T143022".to_string())); - /// - /// // Invalid DateTime returns None - /// let invalid = DateTime { year: 2024, month: 13, day: 1, hour: 0, minute: 0, second: 0 }; - /// assert_eq!(invalid.format(), None); - /// ``` - #[must_use] - pub fn format(&self) -> Option { - if !self.is_valid() { - return None; - } - Some(format!( - "{:04}{:02}{:02}T{:02}{:02}{:02}", - self.year, self.month, self.day, self.hour, self.minute, self.second - )) - } -} - -/// Pack a DateTime into MTP string format. -/// -/// Returns an error if the DateTime contains invalid values. -pub fn pack_datetime(dt: &DateTime) -> Result, crate::Error> { - let formatted = dt.format().ok_or_else(|| { - crate::Error::invalid_data(format!( - "invalid DateTime: year={}, month={}, day={}, hour={}, minute={}, second={}", - dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second - )) - })?; - Ok(pack_string(&formatted)) -} - -/// Unpack a DateTime from a buffer. -/// -/// Returns the datetime (or None for empty string) and the number of bytes consumed. -pub fn unpack_datetime(buf: &[u8]) -> Result<(Option, usize), crate::Error> { - let (s, consumed) = unpack_string(buf)?; - - if s.is_empty() { - return Ok((None, consumed)); - } - - let dt = DateTime::parse(&s) - .ok_or_else(|| crate::Error::invalid_data(format!("invalid datetime format: {}", s)))?; - - Ok((Some(dt), consumed)) -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - // --- DateTime parsing tests --- - - #[test] - fn datetime_parse_basic() { - let dt = DateTime::parse("20240315T143022").unwrap(); - assert_eq!( - (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second), - (2024, 3, 15, 14, 30, 22) - ); - } - - #[test] - fn datetime_parse_with_timezone_z() { - assert!(DateTime::parse("20240315T143022Z").is_some()); - } - - #[test] - fn datetime_parse_with_timezone_positive() { - assert!(DateTime::parse("20240315T143022+0530").is_some()); - } - - #[test] - fn datetime_parse_with_timezone_negative() { - assert!(DateTime::parse("20240315T143022-0800").is_some()); - } - - #[test] - fn datetime_parse_invalid_too_short() { - for s in ["2024031", ""] { - assert!(DateTime::parse(s).is_none()); - } - } - - #[test] - fn datetime_parse_invalid_no_t_separator() { - for s in ["20240315 143022", "20240315143022"] { - assert!(DateTime::parse(s).is_none()); - } - } - - #[test] - fn datetime_parse_invalid_month() { - for s in ["20240015T143022", "20241315T143022"] { - // month=0, month=13 - assert!(DateTime::parse(s).is_none()); - } - } - - #[test] - fn datetime_parse_invalid_day() { - for s in ["20240100T143022", "20240132T143022"] { - // day=0, day=32 - assert!(DateTime::parse(s).is_none()); - } - } - - #[test] - fn datetime_parse_invalid_hour() { - assert!(DateTime::parse("20240315T243022").is_none()); // hour=24 - } - - #[test] - fn datetime_parse_invalid_minute() { - assert!(DateTime::parse("20240315T146022").is_none()); // minute=60 - } - - #[test] - fn datetime_parse_invalid_second() { - assert!(DateTime::parse("20240315T143060").is_none()); // second=60 - } - - // --- DateTime format tests --- - - #[test] - fn datetime_format() { - assert_eq!( - DateTime::new(2024, 3, 15, 14, 30, 22).unwrap().format(), - Some("20240315T143022".into()) - ); - } - - #[test] - fn datetime_format_with_leading_zeros() { - assert_eq!( - DateTime::new(2024, 1, 5, 9, 5, 3).unwrap().format(), - Some("20240105T090503".into()) - ); - } - - #[test] - fn datetime_roundtrip() { - let original = DateTime::new(2024, 12, 31, 23, 59, 59).unwrap(); - assert_eq!( - DateTime::parse(&original.format().unwrap()).unwrap(), - original - ); - } - - #[test] - fn datetime_format_invalid_returns_none() { - let invalid_cases = [ - (2024, 13, 1, 0, 0, 0), // invalid month - (2024, 1, 1, 0, 60, 0), // invalid minute - (10000, 1, 1, 0, 0, 0), // year too large - ]; - for (y, mo, d, h, mi, s) in invalid_cases { - let dt = DateTime { - year: y, - month: mo, - day: d, - hour: h, - minute: mi, - second: s, - }; - assert_eq!(dt.format(), None); - } - } - - #[test] - fn datetime_default() { - let dt = DateTime::default(); - assert_eq!( - (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second), - (0, 0, 0, 0, 0, 0) - ); - } - - // --- Pack/unpack tests --- - - #[test] - fn pack_datetime_basic() { - let packed = pack_datetime(&DateTime::new(2024, 3, 15, 14, 30, 22).unwrap()).unwrap(); - assert_eq!(packed[0], 16); // 15 chars + null terminator - } - - #[test] - fn pack_datetime_invalid_returns_error() { - let invalid = DateTime { - year: 2024, - month: 13, - day: 1, - hour: 0, - minute: 0, - second: 0, - }; - assert!(pack_datetime(&invalid).is_err()); - } - - #[test] - fn unpack_datetime_basic() { - let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap(); - let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap(); - assert_eq!(unpacked, Some(dt)); - } - - #[test] - fn unpack_datetime_empty_string() { - let (dt, consumed) = unpack_datetime(&[0x00]).unwrap(); - assert_eq!((dt, consumed), (None, 1)); - } - - #[test] - fn unpack_datetime_invalid_format() { - assert!(unpack_datetime(&pack_string("not a date")).is_err()); - } - - #[test] - fn datetime_pack_unpack_roundtrip() { - for dt in [ - DateTime::new(2024, 1, 1, 0, 0, 0).unwrap(), - DateTime::new(2024, 12, 31, 23, 59, 59).unwrap(), - DateTime::new(1999, 6, 15, 12, 30, 45).unwrap(), - ] { - let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap(); - assert_eq!(unpacked, Some(dt)); - } - } - - // --- Boundary tests --- - - #[test] - fn datetime_boundary_day_31() { - let dt = DateTime { - year: 2024, - month: 1, - day: 31, - hour: 0, - minute: 0, - second: 0, - }; - let parsed = DateTime::parse(&dt.format().unwrap()).unwrap(); - assert_eq!(parsed.day, 31); - } - - #[test] - fn datetime_boundary_year_0() { - let dt = DateTime { - year: 0, - month: 1, - day: 1, - hour: 0, - minute: 0, - second: 0, - }; - if let Some(p) = DateTime::parse(&dt.format().unwrap()) { - assert_eq!(p.year, 0); - } - } - - #[test] - fn datetime_boundary_year_9999() { - let dt = DateTime { - year: 9999, - month: 12, - day: 31, - hour: 23, - minute: 59, - second: 59, - }; - assert_eq!(DateTime::parse(&dt.format().unwrap()).unwrap().year, 9999); - } - - #[test] - fn datetime_boundary_year_10000() { - let dt = DateTime { - year: 10000, - month: 1, - day: 1, - hour: 0, - minute: 0, - second: 0, - }; - assert!(dt.format().is_none()); - assert!(pack_datetime(&dt).is_err()); - } - - // --- Property-based tests --- - - fn valid_datetime() -> impl Strategy { - ( - 1000u16..9999u16, - 1u8..=12u8, - 1u8..=28u8, - 0u8..=23u8, - 0u8..=59u8, - 0u8..=59u8, - ) - .prop_map(|(year, month, day, hour, minute, second)| DateTime { - year, - month, - day, - hour, - minute, - second, - }) - } - - proptest! { - #[test] - fn prop_datetime_format_parse_roundtrip(dt in valid_datetime()) { - let formatted = dt.format().unwrap(); - prop_assert_eq!(DateTime::parse(&formatted).unwrap(), dt); - } - - #[test] - fn prop_datetime_pack_unpack_roundtrip(dt in valid_datetime()) { - let packed = pack_datetime(&dt).unwrap(); - let (unpacked, consumed) = unpack_datetime(&packed).unwrap(); - prop_assert_eq!(unpacked, Some(dt)); - prop_assert_eq!(consumed, packed.len()); - } - - #[test] - fn prop_datetime_format_length(dt in valid_datetime()) { - prop_assert_eq!(dt.format().unwrap().len(), 15); - } - - #[test] - fn fuzz_datetime_invalid_month( - year in 1900u16..2100u16, - month in prop::sample::select(vec![0u8, 13, 14, 99, 255]), - day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_invalid_day( - year in 1900u16..2100u16, month in 1u8..=12u8, - day in prop::sample::select(vec![0u8, 32, 33, 99, 255]), - hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_invalid_hour( - year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, - hour in prop::sample::select(vec![24u8, 25, 99, 255]), - minute in 0u8..=59u8, second in 0u8..=59u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_invalid_minute( - year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, - minute in prop::sample::select(vec![60u8, 61, 99]), - second in 0u8..=59u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_minute_overflow( - year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, - minute in 100u8..=255u8, second in 0u8..=59u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_invalid_second( - year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, - second in prop::sample::select(vec![60u8, 61, 99]), - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_second_overflow( - year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, - second in 100u8..=255u8, - ) { - let dt = DateTime { year, month, day, hour, minute, second }; - prop_assert!(dt.format().is_none()); - prop_assert!(pack_datetime(&dt).is_err()); - } - - #[test] - fn fuzz_datetime_parse_garbage(s in ".*") { - let _ = DateTime::parse(&s); - } - - #[test] - fn fuzz_datetime_parse_malformed(prefix in "[0-9]{0,20}", suffix in "[^T]*") { - let _ = DateTime::parse(&format!("{}{}", prefix, suffix)); - } - } - - crate::fuzz_bytes_fn!(fuzz_unpack_datetime, unpack_datetime, 50); -} diff --git a/vendor/mtp-rs/src/ptp/pack/mod.rs b/vendor/mtp-rs/src/ptp/pack/mod.rs deleted file mode 100644 index 852f72f..0000000 --- a/vendor/mtp-rs/src/ptp/pack/mod.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! Binary serialization/deserialization primitives for MTP/PTP. -//! -//! All multi-byte values are little-endian. - -mod datetime; - -pub use datetime::{pack_datetime, unpack_datetime, DateTime}; - -// --- Primitive packing functions --- - -/// Pack a u8 value into a 1-byte array. -#[inline] -pub fn pack_u8(val: u8) -> [u8; 1] { - [val] -} - -/// Pack a u16 value into a 2-byte array (little-endian). -#[inline] -pub fn pack_u16(val: u16) -> [u8; 2] { - val.to_le_bytes() -} - -/// Pack a u32 value into a 4-byte array (little-endian). -#[inline] -pub fn pack_u32(val: u32) -> [u8; 4] { - val.to_le_bytes() -} - -/// Pack a u64 value into an 8-byte array (little-endian). -#[inline] -pub fn pack_u64(val: u64) -> [u8; 8] { - val.to_le_bytes() -} - -/// Pack a signed 8-bit integer. -#[inline] -pub fn pack_i8(val: i8) -> [u8; 1] { - [val as u8] -} - -/// Pack a signed 16-bit integer (little-endian). -#[inline] -pub fn pack_i16(val: i16) -> [u8; 2] { - val.to_le_bytes() -} - -/// Pack a signed 32-bit integer (little-endian). -#[inline] -pub fn pack_i32(val: i32) -> [u8; 4] { - val.to_le_bytes() -} - -/// Pack a signed 64-bit integer (little-endian). -#[inline] -pub fn pack_i64(val: i64) -> [u8; 8] { - val.to_le_bytes() -} - -// --- Primitive unpacking functions --- - -/// Unpack a u8 value from a buffer. -pub fn unpack_u8(buf: &[u8]) -> Result { - if buf.is_empty() { - return Err(crate::Error::invalid_data( - "insufficient bytes for u8: need 1, have 0", - )); - } - Ok(buf[0]) -} - -/// Unpack a u16 value from a buffer (little-endian). -pub fn unpack_u16(buf: &[u8]) -> Result { - if buf.len() < 2 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for u16: need 2, have {}", - buf.len() - ))); - } - Ok(u16::from_le_bytes([buf[0], buf[1]])) -} - -/// Unpack a u32 value from a buffer (little-endian). -pub fn unpack_u32(buf: &[u8]) -> Result { - if buf.len() < 4 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for u32: need 4, have {}", - buf.len() - ))); - } - Ok(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) -} - -/// Unpack a u64 value from a buffer (little-endian). -pub fn unpack_u64(buf: &[u8]) -> Result { - if buf.len() < 8 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for u64: need 8, have {}", - buf.len() - ))); - } - Ok(u64::from_le_bytes([ - buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], - ])) -} - -/// Unpack a signed 8-bit integer from a buffer. -pub fn unpack_i8(buf: &[u8]) -> Result { - if buf.is_empty() { - return Err(crate::Error::invalid_data( - "insufficient bytes for i8: need 1, have 0", - )); - } - Ok(buf[0] as i8) -} - -/// Unpack a signed 16-bit integer from a buffer (little-endian). -pub fn unpack_i16(buf: &[u8]) -> Result { - if buf.len() < 2 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for i16: need 2, have {}", - buf.len() - ))); - } - Ok(i16::from_le_bytes([buf[0], buf[1]])) -} - -/// Unpack a signed 32-bit integer from a buffer (little-endian). -pub fn unpack_i32(buf: &[u8]) -> Result { - if buf.len() < 4 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for i32: need 4, have {}", - buf.len() - ))); - } - Ok(i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) -} - -/// Unpack a signed 64-bit integer from a buffer (little-endian). -pub fn unpack_i64(buf: &[u8]) -> Result { - if buf.len() < 8 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for i64: need 8, have {}", - buf.len() - ))); - } - Ok(i64::from_le_bytes([ - buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], - ])) -} - -// --- String encoding/decoding --- - -/// Pack a string into MTP format (UTF-16LE with length prefix). -/// -/// MTP strings are encoded as: -/// 1. 1 byte: Number of characters (including null terminator) -/// 2. N * 2 bytes: UTF-16LE encoded characters -/// 3. 2 bytes: Null terminator (0x0000) -/// -/// Empty string: Single byte 0x00 -pub fn pack_string(s: &str) -> Vec { - if s.is_empty() { - return vec![0x00]; - } - - // Encode to UTF-16 - let utf16: Vec = s.encode_utf16().collect(); - - // Length includes the null terminator - let len = utf16.len() + 1; - - // Allocate result: 1 byte length + (len * 2) bytes for UTF-16 data - let mut result = Vec::with_capacity(1 + len * 2); - - // Length byte (number of characters including null terminator) - result.push(len as u8); - - // UTF-16LE encoded characters - for code_unit in &utf16 { - result.extend_from_slice(&code_unit.to_le_bytes()); - } - - // Null terminator - result.extend_from_slice(&[0x00, 0x00]); - - result -} - -/// Unpack an MTP string from a buffer. -/// -/// Returns the decoded string and the number of bytes consumed. -pub fn unpack_string(buf: &[u8]) -> Result<(String, usize), crate::Error> { - if buf.is_empty() { - return Err(crate::Error::invalid_data( - "insufficient bytes for string length", - )); - } - - let len = buf[0] as usize; - - // Empty string - if len == 0 { - return Ok((String::new(), 1)); - } - - // Calculate required bytes: 1 (length) + len * 2 (UTF-16 code units) - let required = 1 + len * 2; - if buf.len() < required { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for string: need {}, have {}", - required, - buf.len() - ))); - } - - // Decode UTF-16LE code units - let mut code_units = Vec::with_capacity(len); - for i in 0..len { - let offset = 1 + i * 2; - let code_unit = u16::from_le_bytes([buf[offset], buf[offset + 1]]); - code_units.push(code_unit); - } - - // Remove null terminator if present - if code_units.last() == Some(&0) { - code_units.pop(); - } - - // Decode UTF-16 to String - let s = String::from_utf16(&code_units) - .map_err(|_| crate::Error::invalid_data("invalid UTF-16 encoding"))?; - - Ok((s, required)) -} - -// --- Array encoding/decoding --- - -/// Pack a u16 array into MTP format. -/// -/// Arrays are encoded as: -/// 1. 4 bytes: Element count (u32, little-endian) -/// 2. N * 2 bytes: Elements (u16, little-endian each) -pub fn pack_u16_array(arr: &[u16]) -> Vec { - let mut result = Vec::with_capacity(4 + arr.len() * 2); - - // Element count - result.extend_from_slice(&pack_u32(arr.len() as u32)); - - // Elements - for &val in arr { - result.extend_from_slice(&pack_u16(val)); - } - - result -} - -/// Pack a u32 array into MTP format. -/// -/// Arrays are encoded as: -/// 1. 4 bytes: Element count (u32, little-endian) -/// 2. N * 4 bytes: Elements (u32, little-endian each) -pub fn pack_u32_array(arr: &[u32]) -> Vec { - let mut result = Vec::with_capacity(4 + arr.len() * 4); - - // Element count - result.extend_from_slice(&pack_u32(arr.len() as u32)); - - // Elements - for &val in arr { - result.extend_from_slice(&pack_u32(val)); - } - - result -} - -/// Unpack a u16 array from a buffer. -/// -/// Returns the array and the number of bytes consumed. -pub fn unpack_u16_array(buf: &[u8]) -> Result<(Vec, usize), crate::Error> { - if buf.len() < 4 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for array count: need 4, have {}", - buf.len() - ))); - } - - let count = unpack_u32(buf)? as usize; - let required = 4 + count * 2; - - if buf.len() < required { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for u16 array: need {}, have {}", - required, - buf.len() - ))); - } - - let mut result = Vec::with_capacity(count); - for i in 0..count { - let offset = 4 + i * 2; - result.push(unpack_u16(&buf[offset..])?); - } - - Ok((result, required)) -} - -/// Unpack a u32 array from a buffer. -/// -/// Returns the array and the number of bytes consumed. -pub fn unpack_u32_array(buf: &[u8]) -> Result<(Vec, usize), crate::Error> { - if buf.len() < 4 { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for array count: need 4, have {}", - buf.len() - ))); - } - - let count = unpack_u32(buf)? as usize; - let required = 4 + count * 4; - - if buf.len() < required { - return Err(crate::Error::invalid_data(format!( - "insufficient bytes for u32 array: need {}, have {}", - required, - buf.len() - ))); - } - - let mut result = Vec::with_capacity(count); - for i in 0..count { - let offset = 4 + i * 4; - result.push(unpack_u32(&buf[offset..])?); - } - - Ok((result, required)) -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - // --- Primitive pack/unpack tests --- - - #[test] - fn pack_primitives_little_endian() { - // Verify little-endian byte order - assert_eq!(pack_u16(0x1234), [0x34, 0x12]); - assert_eq!(pack_u32(0x12345678), [0x78, 0x56, 0x34, 0x12]); - assert_eq!( - pack_u64(0x0102030405060708), - [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01] - ); - assert_eq!(pack_i16(-1), [0xFF, 0xFF]); - assert_eq!(pack_i32(-1), [0xFF, 0xFF, 0xFF, 0xFF]); - } - - #[test] - fn unpack_insufficient_bytes() { - assert!(unpack_u8(&[]).is_err()); - assert!(unpack_u16(&[0x00]).is_err()); - assert!(unpack_u32(&[0x00, 0x00, 0x00]).is_err()); - assert!(unpack_u64(&[0x00; 7]).is_err()); - assert!(unpack_i8(&[]).is_err()); - assert!(unpack_i16(&[0x00]).is_err()); - assert!(unpack_i32(&[0x00, 0x00, 0x00]).is_err()); - assert!(unpack_i64(&[0x00; 7]).is_err()); - } - - // --- String tests --- - - #[test] - fn pack_string_formats() { - assert_eq!(pack_string(""), vec![0x00]); - assert_eq!( - pack_string("Hi"), - vec![0x03, 0x48, 0x00, 0x69, 0x00, 0x00, 0x00] // len=3, 'H', 'i', null - ); - } - - #[test] - fn pack_string_emoji_surrogate_pair() { - let packed = pack_string("\u{1F600}"); - assert_eq!(packed[0], 3); // surrogate pair (2 units) + null - assert_eq!(&packed[1..5], &[0x3D, 0xD8, 0x00, 0xDE]); // 0xD83D, 0xDE00 - } - - #[test] - fn unpack_string_errors() { - assert!(unpack_string(&[]).is_err()); - assert!(unpack_string(&[0x03, 0x41, 0x00]).is_err()); // truncated - // Invalid surrogate pair - assert!(unpack_string(&[0x02, 0x00, 0xD8, 0x00, 0x00]).is_err()); - } - - #[test] - fn roundtrip_strings() { - for s in ["", "Hello", "\u{3053}\u{3093}", "\u{1F600}"] { - let (unpacked, _) = unpack_string(&pack_string(s)).unwrap(); - assert_eq!(unpacked, s); - } - } - - // --- Array tests --- - - #[test] - fn pack_arrays() { - assert_eq!(pack_u16_array(&[]), vec![0x00, 0x00, 0x00, 0x00]); - assert_eq!( - pack_u16_array(&[1, 2]), - vec![0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00] - ); - assert_eq!( - pack_u32_array(&[1]), - vec![0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00] - ); - } - - #[test] - fn unpack_array_errors() { - assert!(unpack_u16_array(&[]).is_err()); - assert!(unpack_u32_array(&[0x00, 0x00, 0x00]).is_err()); - // Count says 2, only 1 element - assert!(unpack_u16_array(&[0x02, 0x00, 0x00, 0x00, 0x01, 0x00]).is_err()); - assert!(unpack_u32_array(&[0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]).is_err()); - } - - #[test] - fn roundtrip_arrays() { - for arr in [vec![], vec![1u16, 2, 3], vec![0xFFFF]] { - let (unpacked, _) = unpack_u16_array(&pack_u16_array(&arr)).unwrap(); - assert_eq!(unpacked, arr); - } - for arr in [vec![], vec![1u32, 2, 3], vec![0xFFFFFFFF]] { - let (unpacked, _) = unpack_u32_array(&pack_u32_array(&arr)).unwrap(); - assert_eq!(unpacked, arr); - } - } - - // --- Property-based tests --- - - fn valid_utf16_string() -> impl Strategy { - prop::collection::vec( - prop::char::range('\u{0000}', '\u{D7FF}') - .prop_union(prop::char::range('\u{E000}', '\u{FFFF}')), - 0..100, - ) - .prop_map(|chars| chars.into_iter().collect()) - } - - proptest! { - #[test] - fn prop_roundtrip_primitives( - u8_val: u8, u16_val: u16, u32_val: u32, u64_val: u64, - i8_val: i8, i16_val: i16, i32_val: i32, i64_val: i64 - ) { - prop_assert_eq!(unpack_u8(&pack_u8(u8_val)).unwrap(), u8_val); - prop_assert_eq!(unpack_u16(&pack_u16(u16_val)).unwrap(), u16_val); - prop_assert_eq!(unpack_u32(&pack_u32(u32_val)).unwrap(), u32_val); - prop_assert_eq!(unpack_u64(&pack_u64(u64_val)).unwrap(), u64_val); - prop_assert_eq!(unpack_i8(&pack_i8(i8_val)).unwrap(), i8_val); - prop_assert_eq!(unpack_i16(&pack_i16(i16_val)).unwrap(), i16_val); - prop_assert_eq!(unpack_i32(&pack_i32(i32_val)).unwrap(), i32_val); - prop_assert_eq!(unpack_i64(&pack_i64(i64_val)).unwrap(), i64_val); - } - - #[test] - fn prop_roundtrip_string(s in valid_utf16_string()) { - let s: String = s.chars().take(254).collect(); - let packed = pack_string(&s); - let (unpacked, consumed) = unpack_string(&packed).unwrap(); - prop_assert_eq!(&unpacked, &s); - prop_assert_eq!(consumed, packed.len()); - } - - #[test] - fn prop_roundtrip_arrays( - u16_arr in prop::collection::vec(any::(), 0..50), - u32_arr in prop::collection::vec(any::(), 0..50) - ) { - let packed16 = pack_u16_array(&u16_arr); - let (unpacked16, consumed16) = unpack_u16_array(&packed16).unwrap(); - prop_assert_eq!(&unpacked16, &u16_arr); - prop_assert_eq!(consumed16, packed16.len()); - - let packed32 = pack_u32_array(&u32_arr); - let (unpacked32, consumed32) = unpack_u32_array(&packed32).unwrap(); - prop_assert_eq!(&unpacked32, &u32_arr); - prop_assert_eq!(consumed32, packed32.len()); - } - - #[test] - fn prop_unpack_ignores_extra_bytes(val: u32, extra in prop::collection::vec(any::(), 0..10)) { - let mut buf = pack_u32(val).to_vec(); - buf.extend_from_slice(&extra); - prop_assert_eq!(unpack_u32(&buf).unwrap(), val); - } - - #[test] - fn fuzz_truncated_buffers(bytes in prop::collection::vec(any::(), 1..8)) { - if bytes.len() < 2 { prop_assert!(unpack_u16(&bytes).is_err()); } - if bytes.len() < 4 { prop_assert!(unpack_u32(&bytes).is_err()); } - if bytes.len() < 8 { prop_assert!(unpack_u64(&bytes).is_err()); } - if bytes.len() < 2 { prop_assert!(unpack_i16(&bytes).is_err()); } - if bytes.len() < 4 { prop_assert!(unpack_i32(&bytes).is_err()); } - if bytes.len() < 8 { prop_assert!(unpack_i64(&bytes).is_err()); } - } - - #[test] - fn fuzz_array_invalid_count( - claimed_count in 2u32..100u32, - actual_elements in prop::collection::vec(any::(), 0..5) - ) { - let mut buf = pack_u32(claimed_count).to_vec(); - for elem in &actual_elements { - buf.extend_from_slice(&pack_u32(*elem)); - } - if claimed_count as usize > actual_elements.len() { - prop_assert!(unpack_u32_array(&buf).is_err()); - } - } - - #[test] - fn fuzz_large_array_count(count in (u32::MAX - 100)..=u32::MAX) { - prop_assert!(unpack_u32_array(&pack_u32(count)).is_err()); - prop_assert!(unpack_u16_array(&pack_u32(count)).is_err()); - } - } - - // Fuzz tests - verify parsers don't panic on arbitrary input - crate::fuzz_bytes_fn!(fuzz_unpack_string, unpack_string, 100); - crate::fuzz_bytes_fn!(fuzz_unpack_u16_array, unpack_u16_array, 50); - crate::fuzz_bytes_fn!(fuzz_unpack_u32_array, unpack_u32_array, 50); -} diff --git a/vendor/mtp-rs/src/ptp/session/mod.rs b/vendor/mtp-rs/src/ptp/session/mod.rs deleted file mode 100644 index 3223669..0000000 --- a/vendor/mtp-rs/src/ptp/session/mod.rs +++ /dev/null @@ -1,889 +0,0 @@ -//! PTP session management. -//! -//! This module provides session-level operations for MTP/PTP communication. -//! A session maintains the connection state and serializes concurrent operations. - -mod operations; -mod properties; -mod streaming; - -pub use streaming::{receive_stream_to_stream, ReceiveStream}; - -use crate::ptp::{ - container_type, pack_u16, pack_u32, unpack_u32, CommandContainer, ContainerType, DataContainer, - OperationCode, ResponseCode, ResponseContainer, SessionId, TransactionId, -}; -use crate::transport::Transport; -use crate::Error; -use futures::lock::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; - -/// Container header size in bytes. -pub(crate) const HEADER_SIZE: usize = 12; - -/// A PTP session with a device. -/// -/// PtpSession manages the lifecycle of a PTP/MTP session, including: -/// - Opening and closing sessions -/// - Transaction ID management -/// - Serializing concurrent operations (MTP only allows one operation at a time) -/// - Executing operations and receiving responses -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::ptp::PtpDevice; -/// -/// # async fn example() -> Result<(), mtp_rs::Error> { -/// let device = PtpDevice::open_first().await?; -/// let session = device.open_session().await?; -/// -/// // Get device info -/// let device_info = session.get_device_info().await?; -/// -/// // Get storage IDs -/// let storage_ids = session.get_storage_ids().await?; -/// -/// // Close the session when done -/// session.close().await?; -/// # Ok(()) -/// # } -/// ``` -pub struct PtpSession { - /// The transport layer for USB communication. - pub(crate) transport: Arc, - /// The session ID assigned to this session. - session_id: SessionId, - /// Atomic counter for generating transaction IDs. - transaction_id: AtomicU32, - /// Mutex to serialize operations (MTP only allows one operation at a time). - /// Wrapped in Arc so it can be shared with ReceiveStream. - pub(crate) operation_lock: Arc>, - /// Whether to send data container headers separately from payloads. - /// Some devices require the 12-byte PTP header and data payload to arrive - /// as separate USB bulk transfers. - split_header_data: AtomicBool, -} - -impl PtpSession { - /// Create a new session (internal, use open() to start session). - fn new(transport: Arc, session_id: SessionId) -> Self { - Self { - transport, - session_id, - transaction_id: AtomicU32::new(TransactionId::FIRST.0), - operation_lock: Arc::new(Mutex::new(())), - split_header_data: AtomicBool::new(false), - } - } - - /// Enable or disable split header/data mode for sending data containers. - /// - /// When enabled, the 12-byte PTP container header and payload are sent as - /// separate USB bulk transfers in [`execute_with_send`](Self::execute_with_send). - /// Required by some devices that don't handle a combined header+data - /// bulk transfer. - pub fn set_split_header_data(&self, split: bool) { - self.split_header_data.store(split, Ordering::Relaxed); - } - - /// Whether split header/data mode is currently enabled. - #[must_use] - pub fn is_split_header_data(&self) -> bool { - self.split_header_data.load(Ordering::Relaxed) - } - - /// Open a new session with the device. - /// - /// This sends an OpenSession command to the device and establishes a session - /// with the given session ID. - /// - /// # Arguments - /// - /// * `transport` - The transport layer for USB communication - /// * `session_id` - The session ID to use (typically 1) - /// - /// # Errors - /// - /// Returns an error if the device rejects the session or communication fails. - pub async fn open(transport: Arc, session_id: u32) -> Result { - Self::open_with_reuse(transport, session_id, false).await - } - - /// Open a session, reusing an existing device-side session when one is reported. - pub async fn open_reusing_existing( - transport: Arc, - session_id: u32, - ) -> Result { - Self::open_with_reuse(transport, session_id, true).await - } - - async fn open_with_reuse( - transport: Arc, - session_id: u32, - reuse_existing: bool, - ) -> Result { - let session = Self::new(transport, SessionId(session_id)); - - // PTP spec: OpenSession is a session-less operation, so use tx_id=0. - // Some devices (e.g. Amazon Kindle) enforce this strictly and reject - // OpenSession with tx_id != 0 as InvalidParameter. - let response = Self::send_open_session(&session.transport, session_id).await?; - - if response.code == ResponseCode::Ok { - return Ok(session); - } - - if response.code == ResponseCode::SessionAlreadyOpen { - if reuse_existing { - return Ok(session); - } - - // Session already exists with potentially mismatched transaction ID. - // Close the existing session (ignore errors) and open a fresh one. - let _ = session.execute(OperationCode::CloseSession, &[]).await; - - // Create a new session instance with reset transaction ID counter - let fresh_session = Self::new(Arc::clone(&session.transport), SessionId(session_id)); - - let retry_response = - Self::send_open_session(&fresh_session.transport, session_id).await?; - - if retry_response.code != ResponseCode::Ok { - return Err(Error::Protocol { - code: retry_response.code, - operation: OperationCode::OpenSession, - }); - } - - return Ok(fresh_session); - } - - Err(Error::Protocol { - code: response.code, - operation: OperationCode::OpenSession, - }) - } - - /// Send OpenSession with transaction_id=0 (SESSION_LESS) per the PTP spec. - async fn send_open_session( - transport: &Arc, - session_id: u32, - ) -> Result { - let cmd = CommandContainer { - code: OperationCode::OpenSession, - transaction_id: TransactionId::SESSION_LESS.0, - params: vec![session_id], - }; - transport.send_bulk(&cmd.to_bytes()).await?; - - let response_bytes = transport.receive_bulk(512).await?; - ResponseContainer::from_bytes(&response_bytes) - } - - /// Get the session ID. - #[must_use] - pub fn session_id(&self) -> SessionId { - self.session_id - } - - /// Close the session. - /// - /// This sends a CloseSession command to the device. Errors during close - /// are ignored since the session is being terminated anyway. - pub async fn close(self) -> Result<(), Error> { - let _ = self.execute(OperationCode::CloseSession, &[]).await; - Ok(()) - } - - /// Get the next transaction ID. - /// - /// Transaction IDs start at 1 and wrap correctly, skipping 0 and 0xFFFFFFFF. - pub(crate) fn next_transaction_id(&self) -> u32 { - loop { - let current = self.transaction_id.load(Ordering::SeqCst); - let next = TransactionId(current).next().0; - if self - .transaction_id - .compare_exchange(current, next, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - return current; - } - } - } - - // ========================================================================= - // Core operation execution - // ========================================================================= - - /// Execute a PTP operation without a data phase. - /// - /// Exposed for vendor-specific or otherwise non-standard PTP operations - /// that aren't covered by the high-level API. Access via - /// [`MtpDevice::session()`](crate::mtp::MtpDevice::session). - pub async fn execute( - &self, - operation: OperationCode, - params: &[u32], - ) -> Result { - let _guard = self.operation_lock.lock().await; - - let tx_id = self.next_transaction_id(); - - // Build and send command - let cmd = CommandContainer { - code: operation, - transaction_id: tx_id, - params: params.to_vec(), - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - // Receive response - let response_bytes = self.transport.receive_bulk(512).await?; - let response = ResponseContainer::from_bytes(&response_bytes)?; - - // Verify transaction ID matches - if response.transaction_id != tx_id { - return Err(Error::invalid_data(format!( - "Transaction ID mismatch: expected {}, got {}", - tx_id, response.transaction_id - ))); - } - - Ok(response) - } - - /// Execute a PTP operation with a data receive phase. - /// - /// Returns the response container along with the received data payload. - /// Useful for vendor-specific operations that return data. - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::mtp::MtpDevice; - /// use mtp_rs::ptp::{OperationCode, ResponseCode}; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// let device = MtpDevice::open_first().await?; - /// - /// // Execute a vendor-specific operation (0x9501) - /// let (response, data) = device.session() - /// .execute_with_receive(OperationCode::Unknown(0x9501), &[]) - /// .await?; - /// - /// if response.code == ResponseCode::Ok { - /// println!("received {} bytes", data.len()); - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn execute_with_receive( - &self, - operation: OperationCode, - params: &[u32], - ) -> Result<(ResponseContainer, Vec), Error> { - let _guard = self.operation_lock.lock().await; - - let tx_id = self.next_transaction_id(); - - // Send command - let cmd = CommandContainer { - code: operation, - transaction_id: tx_id, - params: params.to_vec(), - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - // Receive data container(s) - // MTP sends data in one or more containers, then response. - // A single data container may span multiple USB transfers if larger than 64KB. - let mut data = Vec::new(); - - loop { - let mut bytes = self.transport.receive_bulk(64 * 1024).await?; - if bytes.is_empty() { - return Err(Error::invalid_data("Empty response")); - } - - let ct = container_type(&bytes)?; - match ct { - ContainerType::Data => { - // Check if we need to receive more data for this container. - // The length field in the header tells us the total container size. - if bytes.len() >= 4 { - let total_length = unpack_u32(&bytes[0..4])? as usize; - // Keep receiving until we have the complete container - while bytes.len() < total_length { - let more = self.transport.receive_bulk(64 * 1024).await?; - if more.is_empty() { - return Err(Error::invalid_data( - "Incomplete data container: device stopped sending", - )); - } - bytes.extend_from_slice(&more); - } - } - let container = DataContainer::from_bytes(&bytes)?; - data.extend_from_slice(&container.payload); - // Continue to receive more containers or response - } - ContainerType::Response => { - let response = ResponseContainer::from_bytes(&bytes)?; - if response.transaction_id != tx_id { - return Err(Error::invalid_data(format!( - "Transaction ID mismatch: expected {}, got {}", - tx_id, response.transaction_id - ))); - } - return Ok((response, data)); - } - _ => { - return Err(Error::invalid_data(format!( - "Unexpected container type: {:?}", - ct - ))); - } - } - } - } - - /// Execute a PTP operation with a data send phase. - /// - /// Sends the provided payload to the device as part of the operation. - /// Useful for vendor-specific operations that require sending data. - /// - /// When [`is_split_header_data`](Self::is_split_header_data) is enabled, the - /// 12-byte PTP container header and the payload are sent as separate USB - /// bulk transfers. - pub async fn execute_with_send( - &self, - operation: OperationCode, - params: &[u32], - data: &[u8], - ) -> Result { - let _guard = self.operation_lock.lock().await; - - let tx_id = self.next_transaction_id(); - - // Send command - let cmd = CommandContainer { - code: operation, - transaction_id: tx_id, - params: params.to_vec(), - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - // Send data container - if self.split_header_data.load(Ordering::Relaxed) { - // Split mode: send the 12-byte header and the payload as two - // separate USB bulk transfers. Required by some devices. - let total_len = (HEADER_SIZE + data.len()) as u32; - let mut header = Vec::with_capacity(HEADER_SIZE); - header.extend_from_slice(&pack_u32(total_len)); - header.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - header.extend_from_slice(&pack_u16(operation.into())); - header.extend_from_slice(&pack_u32(tx_id)); - self.transport.send_bulk(&header).await?; - if !data.is_empty() { - self.transport.send_bulk(data).await?; - } - } else { - let data_container = DataContainer { - code: operation, - transaction_id: tx_id, - payload: data.to_vec(), - }; - self.transport.send_bulk(&data_container.to_bytes()).await?; - } - - // Receive response - let response_bytes = self.transport.receive_bulk(512).await?; - let response = ResponseContainer::from_bytes(&response_bytes)?; - - if response.transaction_id != tx_id { - return Err(Error::invalid_data(format!( - "Transaction ID mismatch: expected {}, got {}", - tx_id, response.transaction_id - ))); - } - - Ok(response) - } - - // ========================================================================= - // Helper methods - // ========================================================================= - - /// Helper to check response is OK. - pub(crate) fn check_response( - response: &ResponseContainer, - operation: OperationCode, - ) -> Result<(), Error> { - if response.code == ResponseCode::Ok { - Ok(()) - } else { - Err(Error::Protocol { - code: response.code, - operation, - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::{pack_u16, pack_u32, ContainerType, ObjectHandle}; - use crate::transport::mock::MockTransport; - - /// Create a mock transport as Arc. - pub(crate) fn mock_transport() -> (Arc, Arc) { - let mock = Arc::new(MockTransport::new()); - let transport: Arc = Arc::clone(&mock) as Arc; - (transport, mock) - } - - /// Build an OK response container bytes. - pub(crate) fn ok_response(tx_id: u32) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&pack_u32(12)); // length - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(ResponseCode::Ok.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf - } - - /// Build a response container with params. - pub(crate) fn response_with_params(tx_id: u32, code: ResponseCode, params: &[u32]) -> Vec { - let len = 12 + params.len() * 4; - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Response.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - for p in params { - buf.extend_from_slice(&pack_u32(*p)); - } - buf - } - - /// Build a data container. - pub(crate) fn data_container(tx_id: u32, code: OperationCode, payload: &[u8]) -> Vec { - let len = 12 + payload.len(); - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(tx_id)); - buf.extend_from_slice(payload); - buf - } - - #[tokio::test] - async fn test_open_session() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession is session-less (tx_id=0) - - let session = PtpSession::open(transport, 1).await.unwrap(); - assert_eq!(session.session_id(), SessionId(1)); - } - - #[tokio::test] - async fn test_open_session_already_open_recovers() { - let (transport, mock) = mock_transport(); - - // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) - mock.queue_response(response_with_params( - 0, - ResponseCode::SessionAlreadyOpen, - &[], - )); - // CloseSession (tx_id=1, first counter value) - mock.queue_response(ok_response(1)); - // Second OpenSession on fresh session (session-less, tx_id=0) - mock.queue_response(ok_response(0)); - - // Should succeed by closing and reopening - let session = PtpSession::open(transport, 1).await.unwrap(); - assert_eq!(session.session_id(), SessionId(1)); - } - - #[tokio::test] - async fn test_open_session_already_open_reuses_without_close() { - let (transport, mock) = mock_transport(); - mock.queue_response(response_with_params( - 0, - ResponseCode::SessionAlreadyOpen, - &[], - )); - - let session = PtpSession::open_reusing_existing(transport, 0xBAAA_AAAD) - .await - .unwrap(); - - assert_eq!(session.session_id(), SessionId(0xBAAA_AAAD)); - assert_eq!(mock.get_sends().len(), 1, "must not send CloseSession"); - } - - #[tokio::test] - async fn test_open_session_already_open_transaction_id_reset() { - let (transport, mock) = mock_transport(); - - // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) - mock.queue_response(response_with_params( - 0, - ResponseCode::SessionAlreadyOpen, - &[], - )); - // CloseSession uses tx_id=1 (first counter value, OpenSession didn't consume one) - mock.queue_response(ok_response(1)); - // Second OpenSession on fresh session (session-less, tx_id=0) - mock.queue_response(ok_response(0)); - // First operation on fresh session uses tx_id=1 - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - - // Verify the fresh session's counter starts at 1 - session.delete_object(ObjectHandle(1)).await.unwrap(); - } - - #[tokio::test] - async fn test_open_session_already_open_close_error_ignored() { - let (transport, mock) = mock_transport(); - - // First OpenSession returns SessionAlreadyOpen (session-less, tx_id=0) - mock.queue_response(response_with_params( - 0, - ResponseCode::SessionAlreadyOpen, - &[], - )); - // CloseSession returns an error (tx_id=1, should be ignored) - mock.queue_response(response_with_params(1, ResponseCode::GeneralError, &[])); - // Second OpenSession succeeds (session-less, tx_id=0) - mock.queue_response(ok_response(0)); - - // Should succeed even if CloseSession fails - let session = PtpSession::open(transport, 1).await.unwrap(); - assert_eq!(session.session_id(), SessionId(1)); - } - - #[tokio::test] - async fn test_open_session_error() { - let (transport, mock) = mock_transport(); - mock.queue_response(response_with_params(0, ResponseCode::GeneralError, &[])); - - let result = PtpSession::open(transport, 1).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_transaction_id_increment() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) - mock.queue_response(ok_response(1)); // First operation - mock.queue_response(ok_response(2)); // Second operation - - let session = PtpSession::open(transport, 1).await.unwrap(); - - // First post-open operation uses tx_id=1, second uses tx_id=2 - session.delete_object(ObjectHandle(1)).await.unwrap(); - session.delete_object(ObjectHandle(2)).await.unwrap(); - } - - #[tokio::test] - async fn test_transaction_id_mismatch() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) - mock.queue_response(ok_response(999)); // Wrong transaction ID - - let session = PtpSession::open(transport, 1).await.unwrap(); - let result = session.delete_object(ObjectHandle(1)).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_execute_with_send_combined_default() { - // By default, command + combined data container = 2 bulk sends. - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // operation response - - let session = PtpSession::open(transport, 1).await.unwrap(); - assert!(!session.is_split_header_data()); - - let payload = vec![0xAA, 0xBB, 0xCC, 0xDD]; - session - .execute_with_send(OperationCode::SendObject, &[], &payload) - .await - .unwrap(); - - // 1 send for OpenSession command + 2 here = 3 total. - let sends = mock.get_sends(); - assert_eq!(sends.len(), 3); - - // Third send is the combined data container: 12-byte header + 4-byte payload. - let data = &sends[2]; - assert_eq!(data.len(), HEADER_SIZE + payload.len()); - assert_eq!(unpack_u32(&data[0..4]).unwrap() as usize, data.len()); - assert_eq!(&data[HEADER_SIZE..], payload.as_slice()); - } - - #[tokio::test] - async fn test_execute_with_send_split_header_data() { - // With split mode enabled, header and payload are sent as 2 separate transfers. - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // operation response - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.set_split_header_data(true); - assert!(session.is_split_header_data()); - - let payload = vec![0xAA, 0xBB, 0xCC, 0xDD]; - session - .execute_with_send(OperationCode::SendObject, &[], &payload) - .await - .unwrap(); - - // OpenSession (1) + command (1) + header (1) + payload (1) = 4 sends. - let sends = mock.get_sends(); - assert_eq!(sends.len(), 4); - - // Third send is the bare 12-byte header reporting the full container length. - let header = &sends[2]; - assert_eq!(header.len(), HEADER_SIZE); - assert_eq!( - unpack_u32(&header[0..4]).unwrap() as usize, - HEADER_SIZE + payload.len() - ); - - // Fourth send is the raw payload, no extra framing. - assert_eq!(sends[3], payload); - } - - #[tokio::test] - async fn test_execute_with_send_split_empty_payload() { - // With split mode and an empty payload, only the header is sent (no second transfer). - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.set_split_header_data(true); - - session - .execute_with_send(OperationCode::SendObject, &[], &[]) - .await - .unwrap(); - - // OpenSession + command + header only = 3. - let sends = mock.get_sends(); - assert_eq!(sends.len(), 3); - assert_eq!(sends[2].len(), HEADER_SIZE); - } - - #[tokio::test] - async fn test_execute_with_send_stream_combined_default() { - // Combined mode batches header + data into 1 MB USB transfers. - // A small payload fits entirely in one batch. - use bytes::Bytes; - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // operation response - - let session = PtpSession::open(transport, 1).await.unwrap(); - assert!(!session.is_split_header_data()); - - let chunks: Vec> = vec![ - Ok(Bytes::from_static(&[0xAA, 0xBB])), - Ok(Bytes::from_static(&[0xCC, 0xDD])), - ]; - let total_size = 4u64; - - session - .execute_with_send_stream( - OperationCode::SendObject, - &[], - total_size, - stream::iter(chunks), - ) - .await - .unwrap(); - - // OpenSession (1) + command (1) + one batch with header+all data (1) = 3 sends. - let sends = mock.get_sends(); - assert_eq!(sends.len(), 3); - - let data = &sends[2]; - assert_eq!(data.len(), HEADER_SIZE + total_size as usize); - assert_eq!(unpack_u32(&data[0..4]).unwrap() as usize, data.len()); - assert_eq!(&data[HEADER_SIZE..], &[0xAA, 0xBB, 0xCC, 0xDD]); - } - - #[tokio::test] - async fn test_execute_with_send_stream_combined_large_multichunk() { - // Verify combined mode works with a large payload (3 MB across 48 - // chunks of 64KB each). The mock transport uses the default - // send_bulk_streaming which buffers everything into one send_bulk - // call, so we get a single data send. Real USB transports stream - // in 256KB transfers. - use bytes::Bytes; - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // operation response - - let session = PtpSession::open(transport, 1).await.unwrap(); - - let chunk_size = 64 * 1024; - let num_chunks = 48; // 3 MB total - let total_size = (chunk_size * num_chunks) as u64; - let chunks: Vec> = (0..num_chunks) - .map(|i| Ok(Bytes::from(vec![(i % 256) as u8; chunk_size]))) - .collect(); - - session - .execute_with_send_stream( - OperationCode::SendObject, - &[], - total_size, - stream::iter(chunks), - ) - .await - .unwrap(); - - // Verify all data arrived (mock buffers everything into one send). - let sends = mock.get_sends(); - // Skip first 2 sends (OpenSession command + SendObject command). - let data_sends: Vec = sends[2..].iter().flat_map(|s| s.clone()).collect(); - assert_eq!(data_sends.len(), HEADER_SIZE + total_size as usize); - - let payload = &data_sends[HEADER_SIZE..]; - for i in 0..num_chunks { - let chunk_start = i * chunk_size; - let chunk_end = chunk_start + chunk_size; - assert!( - payload[chunk_start..chunk_end] - .iter() - .all(|&b| b == (i % 256) as u8), - "chunk {i} data mismatch" - ); - } - } - - #[tokio::test] - async fn test_execute_with_send_stream_split_header_data() { - // With split mode enabled, the header is sent first as its own bulk - // transfer and then each non-empty chunk is sent as its own transfer. - use bytes::Bytes; - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // operation response - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.set_split_header_data(true); - - let chunks: Vec> = vec![ - Ok(Bytes::from_static(&[0xAA, 0xBB])), - Ok(Bytes::from_static(&[0xCC, 0xDD])), - ]; - let total_size = 4u64; - - session - .execute_with_send_stream( - OperationCode::SendObject, - &[], - total_size, - stream::iter(chunks), - ) - .await - .unwrap(); - - // OpenSession (1) + command (1) + header (1) + chunk1 (1) + chunk2 (1) = 5. - let sends = mock.get_sends(); - assert_eq!(sends.len(), 5); - - // Third send is the bare 12-byte header reporting the full container length. - let header = &sends[2]; - assert_eq!(header.len(), HEADER_SIZE); - assert_eq!( - unpack_u32(&header[0..4]).unwrap() as usize, - HEADER_SIZE + total_size as usize - ); - - // Fourth and fifth sends are the raw chunks, no extra framing. - assert_eq!(sends[3], &[0xAA, 0xBB]); - assert_eq!(sends[4], &[0xCC, 0xDD]); - } - - #[tokio::test] - async fn test_execute_with_send_stream_split_skips_empty_chunks() { - // Empty chunks in split mode must not be sent as zero-length bulk - // transfers (which some devices treat as end-of-transfer markers). - use bytes::Bytes; - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.set_split_header_data(true); - - let chunks: Vec> = vec![ - Ok(Bytes::from_static(&[0xAA])), - Ok(Bytes::new()), // empty — should be skipped - Ok(Bytes::from_static(&[0xBB])), - ]; - let total_size = 2u64; - - session - .execute_with_send_stream( - OperationCode::SendObject, - &[], - total_size, - stream::iter(chunks), - ) - .await - .unwrap(); - - // OpenSession + command + header + chunk1 + chunk3 = 5 (the empty - // chunk in the middle must not produce a send). - let sends = mock.get_sends(); - assert_eq!(sends.len(), 5); - assert_eq!(sends[2].len(), HEADER_SIZE); - assert_eq!(sends[3], &[0xAA]); - assert_eq!(sends[4], &[0xBB]); - } - - #[tokio::test] - async fn test_close_session() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) - mock.queue_response(ok_response(1)); // CloseSession (tx_id=1) - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.close().await.unwrap(); - } - - #[tokio::test] - async fn test_close_session_ignores_errors() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession (session-less, tx_id=0) - mock.queue_response(response_with_params(1, ResponseCode::GeneralError, &[])); // CloseSession error (tx_id=1) - - let session = PtpSession::open(transport, 1).await.unwrap(); - // Should succeed even if close fails - session.close().await.unwrap(); - } -} diff --git a/vendor/mtp-rs/src/ptp/session/operations.rs b/vendor/mtp-rs/src/ptp/session/operations.rs deleted file mode 100644 index 6c314b4..0000000 --- a/vendor/mtp-rs/src/ptp/session/operations.rs +++ /dev/null @@ -1,798 +0,0 @@ -//! High-level PTP/MTP operations. -//! -//! This module contains methods for common operations like getting device info, -//! listing storage and objects, downloading/uploading files, etc. - -use crate::ptp::{ - pack_string, unpack_u32_array, unpack_u64, DeviceInfo, EventContainer, ObjectFormatCode, - ObjectHandle, ObjectInfo, ObjectPropertyCode, OperationCode, StorageId, StorageInfo, -}; -use crate::Error; - -use super::PtpSession; - -impl PtpSession { - /// Returns information about the device including its capabilities, - /// manufacturer, model, and supported operations. - pub async fn get_device_info(&self) -> Result { - let (response, data) = self - .execute_with_receive(OperationCode::GetDeviceInfo, &[]) - .await?; - Self::check_response(&response, OperationCode::GetDeviceInfo)?; - DeviceInfo::from_bytes(&data) - } - - /// Returns a list of storage IDs available on the device. - pub async fn get_storage_ids(&self) -> Result, Error> { - let (response, data) = self - .execute_with_receive(OperationCode::GetStorageIds, &[]) - .await?; - Self::check_response(&response, OperationCode::GetStorageIds)?; - let (ids, _) = unpack_u32_array(&data)?; - Ok(ids.into_iter().map(StorageId).collect()) - } - - /// Returns information about a specific storage, including capacity, - /// free space, and filesystem type. - pub async fn get_storage_info(&self, storage_id: StorageId) -> Result { - let (response, data) = self - .execute_with_receive(OperationCode::GetStorageInfo, &[storage_id.0]) - .await?; - Self::check_response(&response, OperationCode::GetStorageInfo)?; - StorageInfo::from_bytes(&data) - } - - /// Get object handles. - /// - /// Returns a list of object handles matching the specified criteria. - /// - /// # Arguments - /// - /// * `storage_id` - Storage to search, or `StorageId::ALL` for all storages - /// * `format` - Filter by format, or `None` for all formats - /// * `parent` - Parent folder handle, or `None` for root level only, - /// or `Some(ObjectHandle::ALL)` for recursive listing - pub async fn get_object_handles( - &self, - storage_id: StorageId, - format: Option, - parent: Option, - ) -> Result, Error> { - let format_code = format.map(|f| u16::from(f) as u32).unwrap_or(0); - let parent_handle = parent.map(|p| p.0).unwrap_or(0); // 0 = root only - - let (response, data) = self - .execute_with_receive( - OperationCode::GetObjectHandles, - &[storage_id.0, format_code, parent_handle], - ) - .await?; - Self::check_response(&response, OperationCode::GetObjectHandles)?; - let (handles, _) = unpack_u32_array(&data)?; - Ok(handles.into_iter().map(ObjectHandle).collect()) - } - - /// Returns metadata about an object, including filename, size, and timestamps. - /// - /// Note: the `size` field is parsed from the standard `ObjectInfo` dataset, which - /// uses a u32 for `ObjectCompressedSize` and saturates at `u32::MAX` for files - /// larger than 4 GB. Use [`get_object_info_full`](Self::get_object_info_full) - /// to auto-resolve the real u64 size via `GetObjectPropValue`. - pub async fn get_object_info(&self, handle: ObjectHandle) -> Result { - let (response, data) = self - .execute_with_receive(OperationCode::GetObjectInfo, &[handle.0]) - .await?; - Self::check_response(&response, OperationCode::GetObjectInfo)?; - ObjectInfo::from_bytes(&data) - } - - /// Returns object metadata with the full 64-bit size resolved. - /// - /// Calls [`get_object_info`](Self::get_object_info) first. If the returned - /// `size` is saturated at `u32::MAX` (indicating the file is larger than 4 GB), - /// it follows up with `GetObjectPropValue(ObjectSize)` to retrieve the real - /// u64 size and overwrites the field before returning. - /// - /// Falls back to the saturated value if the device doesn't support - /// `GetObjectPropValue`, doesn't support the `ObjectSize` property, or if - /// the response can't be parsed. Transport-level errors from the follow-up - /// are swallowed so that listing operations aren't aborted by an optional - /// property lookup; the saturated size is returned instead. - pub async fn get_object_info_full(&self, handle: ObjectHandle) -> Result { - let mut info = self.get_object_info(handle).await?; - if info.size == u64::from(u32::MAX) { - if let Ok(bytes) = self - .get_object_prop_value(handle, ObjectPropertyCode::ObjectSize) - .await - { - if let Ok(real_size) = unpack_u64(&bytes) { - info.size = real_size; - } - } - // Any error (op not supported, prop not supported, parse failure, transport - // error) leaves `info.size` at the saturated u32::MAX. Callers that need the - // exact size for >4 GB objects on devices without GetObjectPropValue support - // must fetch it through another mechanism. - } - Ok(info) - } - - /// Downloads the complete data of an object. - pub async fn get_object(&self, handle: ObjectHandle) -> Result, Error> { - let (response, data) = self - .execute_with_receive(OperationCode::GetObject, &[handle.0]) - .await?; - Self::check_response(&response, OperationCode::GetObject)?; - Ok(data) - } - - /// Get partial object. - /// - /// Downloads a portion of an object's data. Uses the standard `GetPartialObject` - /// operation, which has a 32-bit offset — offsets beyond 4 GB are truncated to - /// u32 and will silently wrap. For files larger than 4 GB, use - /// [`get_partial_object_64()`](Self::get_partial_object_64) instead. - /// - /// # Arguments - /// - /// * `handle` - The object handle - /// * `offset` - Byte offset to start from (truncated to u32) - /// * `max_bytes` - Maximum number of bytes to retrieve - pub async fn get_partial_object( - &self, - handle: ObjectHandle, - offset: u64, - max_bytes: u32, - ) -> Result, Error> { - // GetPartialObject params: handle, offset (u32), max_bytes (u32) - // Note: offset is truncated to u32 in standard MTP - let (response, data) = self - .execute_with_receive( - OperationCode::GetPartialObject, - &[handle.0, offset as u32, max_bytes], - ) - .await?; - Self::check_response(&response, OperationCode::GetPartialObject)?; - Ok(data) - } - - /// Get partial object with a 64-bit offset. - /// - /// Uses the Android/MTP extension `GetPartialObject64` (`0x95C1`) which supports - /// offsets beyond 4 GB. Only devices that advertise this op in their - /// `DeviceInfo::operations_supported` list will accept it; others return - /// `OperationNotSupported`. - /// - /// # Arguments - /// - /// * `handle` - The object handle - /// * `offset` - 64-bit byte offset to start from - /// * `max_bytes` - Maximum number of bytes to retrieve - pub async fn get_partial_object_64( - &self, - handle: ObjectHandle, - offset: u64, - max_bytes: u32, - ) -> Result, Error> { - // GetPartialObject64 params: handle, offset_lo (u32), offset_hi (u32), max_bytes (u32) - let offset_lo = offset as u32; - let offset_hi = (offset >> 32) as u32; - let (response, data) = self - .execute_with_receive( - OperationCode::GetPartialObject64, - &[handle.0, offset_lo, offset_hi, max_bytes], - ) - .await?; - Self::check_response(&response, OperationCode::GetPartialObject64)?; - Ok(data) - } - - /// Downloads the thumbnail image for an object. - pub async fn get_thumb(&self, handle: ObjectHandle) -> Result, Error> { - let (response, data) = self - .execute_with_receive(OperationCode::GetThumb, &[handle.0]) - .await?; - Self::check_response(&response, OperationCode::GetThumb)?; - Ok(data) - } - - /// Send object info (prepare for upload). - /// - /// This must be called before `send_object()` to prepare the device for - /// receiving a new object. - /// - /// # Returns - /// - /// Returns a tuple of (storage_id, parent_handle, new_object_handle) where: - /// - `storage_id` - The storage where the object will be created - /// - `parent_handle` - The parent folder handle - /// - `new_object_handle` - The handle assigned to the new object - pub async fn send_object_info( - &self, - storage_id: StorageId, - parent: ObjectHandle, - info: &ObjectInfo, - ) -> Result<(StorageId, ObjectHandle, ObjectHandle), Error> { - let data = info.to_bytes()?; - let response = self - .execute_with_send( - OperationCode::SendObjectInfo, - &[storage_id.0, parent.0], - &data, - ) - .await?; - Self::check_response(&response, OperationCode::SendObjectInfo)?; - - // Response params: storage_id, parent_handle, object_handle - if response.params.len() < 3 { - return Err(Error::invalid_data( - "SendObjectInfo response missing params", - )); - } - Ok(( - StorageId(response.params[0]), - ObjectHandle(response.params[1]), - ObjectHandle(response.params[2]), - )) - } - - /// Send object data (must follow send_object_info). - /// - /// Uploads the actual data for an object. This must be called immediately - /// after `send_object_info()`. - pub async fn send_object(&self, data: &[u8]) -> Result<(), Error> { - let response = self - .execute_with_send(OperationCode::SendObject, &[], data) - .await?; - Self::check_response(&response, OperationCode::SendObject)?; - Ok(()) - } - - /// Deletes an object from the device. - pub async fn delete_object(&self, handle: ObjectHandle) -> Result<(), Error> { - // Param2 is format code, 0 means any format - let response = self - .execute(OperationCode::DeleteObject, &[handle.0, 0]) - .await?; - Self::check_response(&response, OperationCode::DeleteObject)?; - Ok(()) - } - - /// Moves an object to a different location. - pub async fn move_object( - &self, - handle: ObjectHandle, - storage_id: StorageId, - parent: ObjectHandle, - ) -> Result<(), Error> { - let response = self - .execute( - OperationCode::MoveObject, - &[handle.0, storage_id.0, parent.0], - ) - .await?; - Self::check_response(&response, OperationCode::MoveObject)?; - Ok(()) - } - - /// Copies an object to a new location. - /// Returns the handle of the newly created copy. - pub async fn copy_object( - &self, - handle: ObjectHandle, - storage_id: StorageId, - parent: ObjectHandle, - ) -> Result { - let response = self - .execute( - OperationCode::CopyObject, - &[handle.0, storage_id.0, parent.0], - ) - .await?; - Self::check_response(&response, OperationCode::CopyObject)?; - - if response.params.is_empty() { - return Err(Error::invalid_data("CopyObject response missing handle")); - } - Ok(ObjectHandle(response.params[0])) - } - - /// Get object property value. - /// - /// Retrieves the value of a specific property for an object. - /// This is an MTP extension operation (0x9803). - /// - /// # Arguments - /// - /// * `handle` - The object handle - /// * `property` - The property code to retrieve - /// - /// # Returns - /// - /// Returns the raw property value as bytes. - pub async fn get_object_prop_value( - &self, - handle: ObjectHandle, - property: ObjectPropertyCode, - ) -> Result, Error> { - let (response, data) = self - .execute_with_receive( - OperationCode::GetObjectPropValue, - &[handle.0, u16::from(property) as u32], - ) - .await?; - Self::check_response(&response, OperationCode::GetObjectPropValue)?; - Ok(data) - } - - /// Set object property value. - /// - /// Sets the value of a specific property for an object. - /// This is an MTP extension operation (0x9804). - /// - /// # Arguments - /// - /// * `handle` - The object handle - /// * `property` - The property code to set - /// * `value` - The raw property value as bytes - pub async fn set_object_prop_value( - &self, - handle: ObjectHandle, - property: ObjectPropertyCode, - value: &[u8], - ) -> Result<(), Error> { - let response = self - .execute_with_send( - OperationCode::SetObjectPropValue, - &[handle.0, u16::from(property) as u32], - value, - ) - .await?; - Self::check_response(&response, OperationCode::SetObjectPropValue)?; - Ok(()) - } - - /// Rename an object (file or folder). - /// - /// This is a convenience method that uses SetObjectPropValue to change - /// the ObjectFileName property (0xDC07). - /// - /// # Arguments - /// - /// * `handle` - The object handle to rename - /// * `new_name` - The new filename - /// - /// # Note - /// - /// Not all devices support renaming. Check `supports_rename()` on DeviceInfo first. - pub async fn rename_object(&self, handle: ObjectHandle, new_name: &str) -> Result<(), Error> { - let name_bytes = pack_string(new_name); - self.set_object_prop_value(handle, ObjectPropertyCode::ObjectFileName, &name_bytes) - .await - } - - // --- Capture operations --- - - /// Initiate a capture operation. - /// - /// This triggers the camera to capture an image. The operation is asynchronous; - /// use `poll_event()` to wait for `CaptureComplete` and `ObjectAdded` events. - /// - /// # Arguments - /// - /// * `storage_id` - Target storage (use `StorageId(0)` for camera default) - /// * `format` - Object format for the capture (use `ObjectFormatCode::Undefined` - /// for camera default) - /// - /// # Events - /// - /// After calling this method, monitor for these events: - /// - `EventCode::CaptureComplete` - Capture operation finished - /// - `EventCode::ObjectAdded` - New object (image) was created on device - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::ptp::{PtpDevice, EventCode}; - /// use mtp_rs::{StorageId, ObjectFormatCode}; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// # let device = PtpDevice::open_first().await?; - /// # let session = device.open_session().await?; - /// // Trigger capture - /// session.initiate_capture(StorageId(0), ObjectFormatCode::Undefined).await?; - /// - /// // Wait for events - /// loop { - /// match session.poll_event().await? { - /// Some(event) if event.code == EventCode::CaptureComplete => { - /// println!("Capture complete!"); - /// break; - /// } - /// Some(event) if event.code == EventCode::ObjectAdded => { - /// println!("New object: {}", event.params[0]); - /// } - /// _ => continue, - /// } - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn initiate_capture( - &self, - storage_id: StorageId, - format: ObjectFormatCode, - ) -> Result<(), Error> { - // Per PTP spec, 0x00000000 means "any format" / "use device default". - // ObjectFormatCode::Undefined (0x3000) is different and may not be accepted. - let format_code = match format { - ObjectFormatCode::Undefined => 0, - other => u16::from(other) as u32, - }; - let response = self - .execute(OperationCode::InitiateCapture, &[storage_id.0, format_code]) - .await?; - Self::check_response(&response, OperationCode::InitiateCapture)?; - Ok(()) - } - - // --- Event handling --- - - /// Poll for a single event from the interrupt endpoint. - /// - /// This method awaits **indefinitely** until an event is received from the USB - /// interrupt endpoint. Events are asynchronous notifications from the device - /// about changes such as objects being added/removed, storage changes, etc. - /// Callers should wrap this in an async timeout to avoid blocking forever. - /// - /// Note: This method does not require the operation lock since events are - /// received on the interrupt endpoint, which is independent of bulk transfers. - /// - /// # Returns - /// - /// - `Ok(Some(container))` - An event was received - /// - `Ok(None)` - No event (unreachable in practice; kept for API compatibility) - /// - `Err(_)` - Communication error - pub async fn poll_event(&self) -> Result, Error> { - match self.transport.receive_interrupt().await { - Ok(bytes) => { - let container = EventContainer::from_bytes(&bytes)?; - Ok(Some(container)) - } - Err(Error::Timeout) => Ok(None), - Err(e) => Err(e), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::session::tests::{ - data_container, mock_transport, ok_response, response_with_params, - }; - use crate::ptp::{ - pack_string, pack_u16, pack_u32, pack_u32_array, ContainerType, ResponseCode, - }; - - fn event_container(code: u16, params: [u32; 3]) -> Vec { - let mut buf = Vec::with_capacity(24); - buf.extend_from_slice(&pack_u32(24)); // length = 24 - buf.extend_from_slice(&pack_u16(ContainerType::Event.to_code())); - buf.extend_from_slice(&pack_u16(code)); - buf.extend_from_slice(&pack_u32(0)); // transaction_id - buf.extend_from_slice(&pack_u32(params[0])); - buf.extend_from_slice(&pack_u32(params[1])); - buf.extend_from_slice(&pack_u32(params[2])); - buf - } - - #[tokio::test] - async fn test_get_storage_ids() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // GetStorageIds data response - let storage_ids_data = pack_u32_array(&[0x00010001, 0x00010002]); - mock.queue_response(data_container( - 1, - OperationCode::GetStorageIds, - &storage_ids_data, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let ids = session.get_storage_ids().await.unwrap(); - - assert_eq!(ids, vec![StorageId(0x00010001), StorageId(0x00010002)]); - } - - #[tokio::test] - async fn test_get_object_handles() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // GetObjectHandles data response - let handles_data = pack_u32_array(&[1, 2, 3]); - mock.queue_response(data_container( - 1, - OperationCode::GetObjectHandles, - &handles_data, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let handles = session - .get_object_handles(StorageId::ALL, None, None) - .await - .unwrap(); - - assert_eq!( - handles, - vec![ObjectHandle(1), ObjectHandle(2), ObjectHandle(3)] - ); - } - - #[tokio::test] - async fn test_get_object() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // GetObject data response - let object_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; - mock.queue_response(data_container(1, OperationCode::GetObject, &object_data)); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let data = session.get_object(ObjectHandle(1)).await.unwrap(); - - assert_eq!(data, object_data); - } - - #[tokio::test] - async fn test_delete_object() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // DeleteObject - - let session = PtpSession::open(transport, 1).await.unwrap(); - session.delete_object(ObjectHandle(1)).await.unwrap(); - } - - #[tokio::test] - async fn test_copy_object() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params(1, ResponseCode::Ok, &[100])); // CopyObject with new handle - - let session = PtpSession::open(transport, 1).await.unwrap(); - let new_handle = session - .copy_object(ObjectHandle(1), StorageId(0x00010001), ObjectHandle::ROOT) - .await - .unwrap(); - - assert_eq!(new_handle, ObjectHandle(100)); - } - - // --- Event polling tests --- - - #[tokio::test] - async fn test_poll_event_object_added() { - use crate::ptp::EventCode; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue an ObjectAdded event (code 0x4002) - mock.queue_interrupt(event_container(0x4002, [42, 0, 0])); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let event = session.poll_event().await.unwrap().unwrap(); - - assert_eq!(event.code, EventCode::ObjectAdded); - assert_eq!(event.params[0], 42); - } - - #[tokio::test] - async fn test_poll_event_store_removed() { - use crate::ptp::EventCode; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue a StoreRemoved event (code 0x4005) - mock.queue_interrupt(event_container(0x4005, [0x00010001, 0, 0])); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let event = session.poll_event().await.unwrap().unwrap(); - - assert_eq!(event.code, EventCode::StoreRemoved); - assert_eq!(event.params[0], 0x00010001); - } - - #[tokio::test] - async fn test_poll_event_multiple_events() { - use crate::ptp::EventCode; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue multiple events - mock.queue_interrupt(event_container(0x4002, [1, 0, 0])); // ObjectAdded - mock.queue_interrupt(event_container(0x4002, [2, 0, 0])); // ObjectAdded - mock.queue_interrupt(event_container(0x4003, [1, 0, 0])); // ObjectRemoved - - let session = PtpSession::open(transport, 1).await.unwrap(); - - let event1 = session.poll_event().await.unwrap().unwrap(); - assert_eq!(event1.code, EventCode::ObjectAdded); - assert_eq!(event1.params[0], 1); - - let event2 = session.poll_event().await.unwrap().unwrap(); - assert_eq!(event2.code, EventCode::ObjectAdded); - assert_eq!(event2.params[0], 2); - - let event3 = session.poll_event().await.unwrap().unwrap(); - assert_eq!(event3.code, EventCode::ObjectRemoved); - assert_eq!(event3.params[0], 1); - } - - // --- Object property and rename tests --- - - #[tokio::test] - async fn test_get_object_prop_value() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // GetObjectPropValue data response (property value is raw bytes) - let prop_value = vec![0x05, 0x48, 0x00, 0x69, 0x00, 0x00, 0x00]; // Packed string "Hi" - mock.queue_response(data_container( - 1, - OperationCode::GetObjectPropValue, - &prop_value, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let data = session - .get_object_prop_value(ObjectHandle(1), ObjectPropertyCode::ObjectFileName) - .await - .unwrap(); - - assert_eq!(data, prop_value); - } - - #[tokio::test] - async fn test_set_object_prop_value() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SetObjectPropValue - - let session = PtpSession::open(transport, 1).await.unwrap(); - let prop_value = pack_string("newfile.txt"); - session - .set_object_prop_value( - ObjectHandle(1), - ObjectPropertyCode::ObjectFileName, - &prop_value, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_set_object_prop_value_not_supported() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params( - 1, - ResponseCode::OperationNotSupported, - &[], - )); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let prop_value = pack_string("newfile.txt"); - let result = session - .set_object_prop_value( - ObjectHandle(1), - ObjectPropertyCode::ObjectFileName, - &prop_value, - ) - .await; - - assert!(matches!( - result, - Err(crate::Error::Protocol { - code: ResponseCode::OperationNotSupported, - .. - }) - )); - } - - #[tokio::test] - async fn test_rename_object() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SetObjectPropValue (for rename) - - let session = PtpSession::open(transport, 1).await.unwrap(); - session - .rename_object(ObjectHandle(1), "renamed.txt") - .await - .unwrap(); - } - - #[tokio::test] - async fn test_rename_object_not_supported() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params( - 1, - ResponseCode::OperationNotSupported, - &[], - )); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let result = session.rename_object(ObjectHandle(1), "renamed.txt").await; - - assert!(matches!( - result, - Err(crate::Error::Protocol { - code: ResponseCode::OperationNotSupported, - .. - }) - )); - } - - // --- Capture tests --- - - #[tokio::test] - async fn test_initiate_capture() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // InitiateCapture - - let session = PtpSession::open(transport, 1).await.unwrap(); - session - .initiate_capture(StorageId(0), ObjectFormatCode::Undefined) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_initiate_capture_with_format() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // InitiateCapture - - let session = PtpSession::open(transport, 1).await.unwrap(); - session - .initiate_capture(StorageId(0x00010001), ObjectFormatCode::Jpeg) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_initiate_capture_not_supported() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params( - 1, - ResponseCode::OperationNotSupported, - &[], - )); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let result = session - .initiate_capture(StorageId(0), ObjectFormatCode::Undefined) - .await; - - assert!(matches!( - result, - Err(crate::Error::Protocol { - code: ResponseCode::OperationNotSupported, - .. - }) - )); - } -} diff --git a/vendor/mtp-rs/src/ptp/session/properties.rs b/vendor/mtp-rs/src/ptp/session/properties.rs deleted file mode 100644 index d13851c..0000000 --- a/vendor/mtp-rs/src/ptp/session/properties.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Device property operations. -//! -//! This module contains methods for getting and setting device properties, -//! primarily used for digital cameras to query/set settings like ISO, aperture, etc. - -use crate::ptp::{ - DevicePropDesc, DevicePropertyCode, OperationCode, PropertyDataType, PropertyValue, -}; -use crate::Error; - -use super::PtpSession; - -impl PtpSession { - // ========================================================================= - // Device property operations - // ========================================================================= - - /// Get the descriptor for a device property. - /// - /// Returns detailed information about the property including its type, - /// current value, default value, and allowed values/range. - /// - /// This is primarily used for digital cameras to query settings like - /// ISO, aperture, shutter speed, etc. Most Android MTP devices do not - /// support device properties. - /// - /// # Arguments - /// - /// * `property` - The device property code to query - /// - /// # Example - /// - /// ```rust,no_run - /// use mtp_rs::ptp::{PtpDevice, DevicePropertyCode}; - /// - /// # async fn example() -> Result<(), mtp_rs::Error> { - /// # let device = PtpDevice::open_first().await?; - /// # let session = device.open_session().await?; - /// let desc = session.get_device_prop_desc(DevicePropertyCode::BatteryLevel).await?; - /// println!("Battery level: {:?}", desc.current_value); - /// # Ok(()) - /// # } - /// ``` - pub async fn get_device_prop_desc( - &self, - property: DevicePropertyCode, - ) -> Result { - let (response, data) = self - .execute_with_receive( - OperationCode::GetDevicePropDesc, - &[u16::from(property) as u32], - ) - .await?; - Self::check_response(&response, OperationCode::GetDevicePropDesc)?; - DevicePropDesc::from_bytes(&data) - } - - /// Get the current value of a device property. - /// - /// Returns the raw bytes of the property value. To interpret the value, - /// you need to know the property's data type. Use `get_device_prop_desc()` - /// to get the full descriptor including the data type. - /// - /// # Arguments - /// - /// * `property` - The device property code to query - pub async fn get_device_prop_value( - &self, - property: DevicePropertyCode, - ) -> Result, Error> { - let (response, data) = self - .execute_with_receive( - OperationCode::GetDevicePropValue, - &[u16::from(property) as u32], - ) - .await?; - Self::check_response(&response, OperationCode::GetDevicePropValue)?; - Ok(data) - } - - /// Get a device property value as a typed PropertyValue. - /// - /// This is a convenience method that parses the raw bytes according to - /// the specified data type. - /// - /// # Arguments - /// - /// * `property` - The device property code to query - /// * `data_type` - The expected data type of the property - pub async fn get_device_prop_value_typed( - &self, - property: DevicePropertyCode, - data_type: PropertyDataType, - ) -> Result { - let data = self.get_device_prop_value(property).await?; - let (value, _) = PropertyValue::from_bytes(&data, data_type)?; - Ok(value) - } - - /// Set a device property value. - /// - /// The value should be the raw bytes of the new value. The value type - /// must match the property's data type. - /// - /// # Arguments - /// - /// * `property` - The device property code to set - /// * `value` - The raw bytes of the new value - pub async fn set_device_prop_value( - &self, - property: DevicePropertyCode, - value: &[u8], - ) -> Result<(), Error> { - let response = self - .execute_with_send( - OperationCode::SetDevicePropValue, - &[u16::from(property) as u32], - value, - ) - .await?; - Self::check_response(&response, OperationCode::SetDevicePropValue)?; - Ok(()) - } - - /// Set a device property value from a PropertyValue. - /// - /// This is a convenience method that serializes the PropertyValue to bytes. - /// - /// # Arguments - /// - /// * `property` - The device property code to set - /// * `value` - The new value - pub async fn set_device_prop_value_typed( - &self, - property: DevicePropertyCode, - value: &PropertyValue, - ) -> Result<(), Error> { - let data = value.to_bytes(); - self.set_device_prop_value(property, &data).await - } - - /// Reset a device property to its default value. - /// - /// # Arguments - /// - /// * `property` - The device property code to reset - pub async fn reset_device_prop_value(&self, property: DevicePropertyCode) -> Result<(), Error> { - let response = self - .execute( - OperationCode::ResetDevicePropValue, - &[u16::from(property) as u32], - ) - .await?; - Self::check_response(&response, OperationCode::ResetDevicePropValue)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::session::tests::{ - data_container, mock_transport, ok_response, response_with_params, - }; - use crate::ptp::{pack_u16, ResponseCode}; - - /// Build a battery level property descriptor for testing. - fn build_battery_prop_desc(current: u8) -> Vec { - let mut buf = Vec::new(); - // PropertyCode: 0x5001 (BatteryLevel) - buf.extend_from_slice(&pack_u16(0x5001)); - // DataType: UINT8 (0x0002) - buf.extend_from_slice(&pack_u16(0x0002)); - // GetSet: read-only (0x00) - buf.push(0x00); - // DefaultValue: 100 - buf.push(100); - // CurrentValue - buf.push(current); - // FormFlag: Range (0x01) - buf.push(0x01); - // Range: min=0, max=100, step=1 - buf.push(0); // min - buf.push(100); // max - buf.push(1); // step - buf - } - - #[tokio::test] - async fn test_get_device_prop_desc() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue battery level prop desc - let prop_desc_data = build_battery_prop_desc(75); - mock.queue_response(data_container( - 1, - OperationCode::GetDevicePropDesc, - &prop_desc_data, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let desc = session - .get_device_prop_desc(DevicePropertyCode::BatteryLevel) - .await - .unwrap(); - - assert_eq!(desc.property_code, DevicePropertyCode::BatteryLevel); - assert_eq!(desc.data_type, PropertyDataType::Uint8); - assert!(!desc.writable); - assert_eq!(desc.current_value, PropertyValue::Uint8(75)); - } - - #[tokio::test] - async fn test_get_device_prop_desc_not_supported() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params( - 1, - ResponseCode::DevicePropNotSupported, - &[], - )); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let result = session - .get_device_prop_desc(DevicePropertyCode::BatteryLevel) - .await; - - assert!(matches!( - result, - Err(crate::Error::Protocol { - code: ResponseCode::DevicePropNotSupported, - .. - }) - )); - } - - #[tokio::test] - async fn test_get_device_prop_value() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue battery level value (75%) - let value_data = vec![75u8]; - mock.queue_response(data_container( - 1, - OperationCode::GetDevicePropValue, - &value_data, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let data = session - .get_device_prop_value(DevicePropertyCode::BatteryLevel) - .await - .unwrap(); - - assert_eq!(data, vec![75u8]); - } - - #[tokio::test] - async fn test_get_device_prop_value_typed() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Queue ISO value (400 = 0x0190) - let value_data = vec![0x90, 0x01]; // 400 in little-endian - mock.queue_response(data_container( - 1, - OperationCode::GetDevicePropValue, - &value_data, - )); - mock.queue_response(ok_response(1)); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let value = session - .get_device_prop_value_typed( - DevicePropertyCode::ExposureIndex, - PropertyDataType::Uint16, - ) - .await - .unwrap(); - - assert_eq!(value, PropertyValue::Uint16(400)); - } - - #[tokio::test] - async fn test_set_device_prop_value() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SetDevicePropValue - - let session = PtpSession::open(transport, 1).await.unwrap(); - let value = vec![0x90, 0x01]; // 400 in little-endian - session - .set_device_prop_value(DevicePropertyCode::ExposureIndex, &value) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_set_device_prop_value_typed() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SetDevicePropValue - - let session = PtpSession::open(transport, 1).await.unwrap(); - session - .set_device_prop_value_typed( - DevicePropertyCode::ExposureIndex, - &PropertyValue::Uint16(400), - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_set_device_prop_value_invalid() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(response_with_params( - 1, - ResponseCode::InvalidDevicePropValue, - &[], - )); - - let session = PtpSession::open(transport, 1).await.unwrap(); - let result = session - .set_device_prop_value(DevicePropertyCode::ExposureIndex, &[0x00, 0x00]) - .await; - - assert!(matches!( - result, - Err(crate::Error::Protocol { - code: ResponseCode::InvalidDevicePropValue, - .. - }) - )); - } - - #[tokio::test] - async fn test_reset_device_prop_value() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // ResetDevicePropValue - - let session = PtpSession::open(transport, 1).await.unwrap(); - session - .reset_device_prop_value(DevicePropertyCode::ExposureIndex) - .await - .unwrap(); - } -} diff --git a/vendor/mtp-rs/src/ptp/session/streaming.rs b/vendor/mtp-rs/src/ptp/session/streaming.rs deleted file mode 100644 index 1a26510..0000000 --- a/vendor/mtp-rs/src/ptp/session/streaming.rs +++ /dev/null @@ -1,657 +0,0 @@ -//! Streaming transfer operations. -//! -//! This module contains the `ReceiveStream` struct and methods for streaming -//! data transfers, allowing memory-efficient downloads and uploads without -//! buffering entire files in memory. - -use crate::ptp::{ - container_type, pack_u16, pack_u32, unpack_u32, CommandContainer, ContainerType, ObjectHandle, - OperationCode, ResponseCode, ResponseContainer, -}; -use crate::transport::Transport; -use crate::Error; -use bytes::Bytes; -use futures::lock::OwnedMutexGuard; -use futures::Stream; -use std::sync::Arc; -use std::time::Duration; - -use super::{PtpSession, HEADER_SIZE}; - -impl PtpSession { - // ========================================================================= - // Streaming operations - // ========================================================================= - - /// Execute operation with streaming data receive. - /// - /// Returns a Stream that yields data chunks as they arrive from USB. - /// The stream yields `Bytes` chunks (typically up to 64KB each). - /// - /// # Important - /// - /// The caller must either consume the entire stream or call - /// [`cancel()`](ReceiveStream::cancel) before dropping it. The MTP - /// session is locked while the stream is active. - /// - /// # Arguments - /// - /// * `operation` - The operation code to execute - /// * `params` - Operation parameters - /// - /// # Returns - /// - /// A `ReceiveStream` that yields `Result` chunks. - pub async fn execute_with_receive_stream( - self: &Arc, - operation: OperationCode, - params: &[u32], - ) -> Result { - // Clone the Arc for the lock - let lock = Arc::clone(&self.operation_lock); - let guard = lock.lock_owned().await; - - let tx_id = self.next_transaction_id(); - - // Send command - let cmd = CommandContainer { - code: operation, - transaction_id: tx_id, - params: params.to_vec(), - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - Ok(ReceiveStream { - transport: Arc::clone(&self.transport), - _guard: guard, - transaction_id: tx_id, - operation, - buffer: Vec::new(), - container_length: 0, - payload_yielded: 0, - header_parsed: false, - done: false, - }) - } - - /// Execute operation with streaming data send. - /// - /// Accepts a Stream of data chunks to send. The total_size must be - /// known upfront (MTP protocol requirement). - /// - /// # Arguments - /// - /// * `operation` - The operation code - /// * `params` - Operation parameters - /// * `total_size` - Total bytes that will be sent (REQUIRED by MTP protocol) - /// * `data` - Stream of data chunks to send - /// - /// # Important - /// - /// The `total_size` must match the actual total bytes in the stream. - /// MTP requires knowing the size before transfer begins. - /// - /// When [`is_split_header_data`](Self::is_split_header_data) is enabled, the - /// 12-byte PTP container header and the streamed payload are sent as - /// separate USB bulk transfers, mirroring the behavior of - /// [`execute_with_send`](Self::execute_with_send). - pub async fn execute_with_send_stream( - &self, - operation: OperationCode, - params: &[u32], - total_size: u64, - mut data: S, - ) -> Result - where - S: Stream> + Unpin + Send, - { - use futures::StreamExt; - use std::sync::atomic::Ordering; - - let _guard = self.operation_lock.lock().await; - let tx_id = self.next_transaction_id(); - - // Send command - let cmd = CommandContainer { - code: operation, - transaction_id: tx_id, - params: params.to_vec(), - }; - self.transport.send_bulk(&cmd.to_bytes()).await?; - - let container_length = HEADER_SIZE as u64 + total_size; - - // Build the 12-byte data container header. - let mut header = Vec::with_capacity(HEADER_SIZE); - if container_length <= u32::MAX as u64 { - header.extend_from_slice(&pack_u32(container_length as u32)); - } else { - header.extend_from_slice(&pack_u32(0xFFFFFFFF)); - } - header.extend_from_slice(&pack_u16(ContainerType::Data.to_code())); - header.extend_from_slice(&pack_u16(operation.into())); - header.extend_from_slice(&pack_u32(tx_id)); - - if self.split_header_data.load(Ordering::Relaxed) { - // Split mode: send the header as its own bulk transfer, then send - // each streamed chunk as its own bulk transfer. Required by some - // devices that don't handle a combined header+data bulk transfer. - self.transport.send_bulk(&header).await?; - while let Some(chunk_result) = data.next().await { - let chunk = chunk_result.map_err(Error::Io)?; - if !chunk.is_empty() { - self.transport.send_bulk(&chunk).await?; - } - } - } else { - // Combined mode: stream header + data as one continuous USB - // transfer. The transport handles buffering and ZLP termination, - // so we never buffer the entire file in RAM. - let header_stream = futures::stream::once(async { Ok(Bytes::from(header)) }); - let combined = header_stream.chain(data); - self.transport - .send_bulk_streaming(Box::pin(combined)) - .await?; - } - - // Receive response - let response_bytes = self.transport.receive_bulk(512).await?; - let response = ResponseContainer::from_bytes(&response_bytes)?; - - if response.transaction_id != tx_id { - return Err(Error::invalid_data(format!( - "Transaction ID mismatch: expected {}, got {}", - tx_id, response.transaction_id - ))); - } - - Ok(response) - } - - /// Download an object as a stream of chunks. - /// - /// This is a convenience method that calls `execute_with_receive_stream` - /// with GetObject operation. - /// - /// # Important - /// - /// The caller must either consume the entire stream or call - /// [`cancel()`](ReceiveStream::cancel) before dropping it. The MTP - /// session is locked while the stream is active. - pub async fn get_object_stream( - self: &Arc, - handle: ObjectHandle, - ) -> Result { - self.execute_with_receive_stream(OperationCode::GetObject, &[handle.0]) - .await - } - - /// Upload an object from a stream. - /// - /// This is a convenience method that streams object data directly to USB. - /// - /// # Arguments - /// - /// * `total_size` - Total bytes that will be sent - /// * `data` - Stream of data chunks to send - pub async fn send_object_stream(&self, total_size: u64, data: S) -> Result<(), Error> - where - S: Stream> + Unpin + Send, - { - let response = self - .execute_with_send_stream(OperationCode::SendObject, &[], total_size, data) - .await?; - Self::check_response(&response, OperationCode::SendObject)?; - Ok(()) - } -} - -/// A stream of data chunks received from USB during a download operation. -/// -/// This stream yields `Bytes` chunks as they arrive from the device, -/// allowing memory-efficient streaming without buffering the entire file. -/// -/// # Important -/// -/// The MTP session is locked while this stream exists. You must either -/// consume the entire stream or call [`cancel()`](Self::cancel) before -/// dropping it. Dropping mid-stream without cancelling corrupts the USB -/// session (a `debug_assert` catches this in debug builds). -#[must_use = "dropping a ReceiveStream mid-transfer corrupts the USB session; \ - consume it fully or call cancel()"] -pub struct ReceiveStream { - /// The transport layer for USB communication. - transport: Arc, - /// Guard that holds the operation lock for the duration of streaming. - _guard: OwnedMutexGuard<()>, - /// Transaction ID for this operation. - transaction_id: u32, - /// Operation code for this operation. - operation: OperationCode, - /// Buffer for partial container data. - buffer: Vec, - /// Total length of current container (from header). - container_length: usize, - /// How much payload we've already yielded from current container. - payload_yielded: usize, - /// Whether we've parsed the container header. - header_parsed: bool, - /// Whether the stream is complete. - done: bool, -} - -impl ReceiveStream { - /// Get the transaction ID for this operation. - #[must_use] - pub fn transaction_id(&self) -> u32 { - self.transaction_id - } - - /// Poll for the next chunk of data. - /// - /// This is the async version of the Stream trait's poll_next. - pub async fn next_chunk(&mut self) -> Option> { - if self.done { - return None; - } - - loop { - // If we have buffered data beyond what we've already yielded, yield it - if self.header_parsed { - let payload_start = HEADER_SIZE + self.payload_yielded; - let payload_end = std::cmp::min(self.buffer.len(), self.container_length); - - if payload_start < payload_end { - // We have new data to yield - let chunk_data = self.buffer[payload_start..payload_end].to_vec(); - self.payload_yielded += chunk_data.len(); - - // Check if this container is complete - if self.buffer.len() >= self.container_length { - // Remove this container from buffer - self.buffer.drain(..self.container_length); - self.header_parsed = false; - self.container_length = 0; - self.payload_yielded = 0; - } - - if !chunk_data.is_empty() { - return Some(Ok(Bytes::from(chunk_data))); - } - } else if self.buffer.len() >= self.container_length { - // Container complete but no new data (shouldn't happen, but handle it) - self.buffer.drain(..self.container_length); - self.header_parsed = false; - self.container_length = 0; - self.payload_yielded = 0; - } - } - - // Need more data from USB - match self.transport.receive_bulk(64 * 1024).await { - Ok(bytes) => { - if bytes.is_empty() { - return Some(Err(Error::invalid_data("Empty response from device"))); - } - self.buffer.extend_from_slice(&bytes); - } - Err(e) => { - self.done = true; - return Some(Err(e)); - } - } - - // Try to parse container header if we haven't yet - if !self.header_parsed && self.buffer.len() >= HEADER_SIZE { - let ct = match container_type(&self.buffer) { - Ok(ct) => ct, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - - match ct { - ContainerType::Data => { - let length = match unpack_u32(&self.buffer[0..4]) { - Ok(l) => l as usize, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - self.container_length = length; - self.header_parsed = true; - } - ContainerType::Response => { - // End of data transfer - let response = match ResponseContainer::from_bytes(&self.buffer) { - Ok(r) => r, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - - self.done = true; - - // Check transaction ID - if response.transaction_id != self.transaction_id { - return Some(Err(Error::invalid_data(format!( - "Transaction ID mismatch: expected {}, got {}", - self.transaction_id, response.transaction_id - )))); - } - - // Check response code - if response.code != ResponseCode::Ok { - return Some(Err(Error::Protocol { - code: response.code, - operation: self.operation, - })); - } - - return None; - } - _ => { - self.done = true; - return Some(Err(Error::invalid_data(format!( - "Unexpected container type: {:?}", - ct - )))); - } - } - } - } - } - - /// Cancel the in-progress download. - /// - /// Uses the USB Still Image Class cancel mechanism: sends a CLASS_CANCEL - /// control request to the device, then drains any remaining data from - /// the USB pipes. The session stays healthy for subsequent operations. - /// - /// The `idle_timeout` controls how long to wait during pipe drain before - /// assuming the pipe is clear. 300ms is the recommended default; see - /// [`DEFAULT_CANCEL_TIMEOUT`](crate::mtp::DEFAULT_CANCEL_TIMEOUT). - /// - /// If the stream is already complete, this is a no-op. - pub async fn cancel(&mut self, idle_timeout: Duration) -> Result<(), Error> { - if self.done { - return Ok(()); - } - self.done = true; - self.transport - .cancel_transfer(self.transaction_id, idle_timeout) - .await - } - - /// Collect all remaining data into a `Vec`. - /// - /// This consumes the stream and buffers all data in memory. - pub async fn collect(mut self) -> Result, Error> { - let mut data = Vec::new(); - while let Some(result) = self.next_chunk().await { - let chunk = result?; - data.extend_from_slice(&chunk); - } - Ok(data) - } -} - -impl Drop for ReceiveStream { - fn drop(&mut self) { - debug_assert!( - self.done, - "ReceiveStream dropped without consuming all data or calling cancel(). \ - This corrupts the USB session. Call cancel() before dropping, \ - or consume the stream to completion." - ); - } -} - -/// Convert a ReceiveStream into a futures::Stream using async iteration. -/// -/// This creates a proper Stream that can be used with StreamExt methods. -pub fn receive_stream_to_stream(recv: ReceiveStream) -> impl Stream> { - futures::stream::unfold(recv, |mut recv| async move { - recv.next_chunk().await.map(|result| (result, recv)) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::session::tests::{ - data_container, mock_transport, ok_response, response_with_params, - }; - use crate::ptp::ResponseCode; - - #[tokio::test] - async fn test_receive_stream_small_file() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // GetObject data response (small file fits in one container) - let file_data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - mock.queue_response(ok_response(1)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - - // Use streaming API - let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Collect all chunks - let mut received = Vec::new(); - while let Some(result) = stream.next_chunk().await { - let chunk = result.unwrap(); - received.extend_from_slice(&chunk); - } - - assert_eq!(received, file_data); - } - - #[tokio::test] - async fn test_receive_stream_collect() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3, 4, 5]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - mock.queue_response(ok_response(1)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - - let stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - let collected = stream.collect().await.unwrap(); - - assert_eq!(collected, file_data); - } - - #[tokio::test] - async fn test_receive_stream_error_response() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - // Return error response instead of data - mock.queue_response(response_with_params( - 2, - ResponseCode::InvalidObjectHandle, - &[], - )); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - - let mut stream = session.get_object_stream(ObjectHandle(999)).await.unwrap(); - - // Should get error when reading - let result = stream.next_chunk().await; - assert!(result.is_some()); - let err = result.unwrap(); - assert!(err.is_err()); - } - - #[tokio::test] - async fn test_send_stream_small_file() { - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SendObject response - - let session = PtpSession::open(transport, 1).await.unwrap(); - - // Create a small data stream (use iter instead of once for Unpin) - let data = vec![1u8, 2, 3, 4, 5]; - let data_stream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(data.clone()))]); - - // Send using streaming API - session.send_object_stream(5, data_stream).await.unwrap(); - } - - #[tokio::test] - async fn test_send_stream_multiple_chunks() { - use futures::stream; - - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - mock.queue_response(ok_response(1)); // SendObject response - - let session = PtpSession::open(transport, 1).await.unwrap(); - - // Create a multi-chunk data stream - let chunks = vec![ - Ok::<_, std::io::Error>(Bytes::from(vec![1, 2, 3])), - Ok(Bytes::from(vec![4, 5, 6])), - Ok(Bytes::from(vec![7, 8, 9, 10])), - ]; - let data_stream = stream::iter(chunks); - - // Send using streaming API (total size = 10) - session.send_object_stream(10, data_stream).await.unwrap(); - } - - #[tokio::test] - async fn test_receive_stream_to_stream_conversion() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3, 4, 5]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - mock.queue_response(ok_response(1)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - - let recv_stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Convert to futures::Stream and use StreamExt - // Use pin_mut! to make it Unpin - use futures::StreamExt; - use std::pin::pin; - let mut stream = pin!(receive_stream_to_stream(recv_stream)); - - let mut collected = Vec::new(); - while let Some(result) = stream.next().await { - collected.extend_from_slice(&result.unwrap()); - } - - assert_eq!(collected, file_data); - } - - #[tokio::test] - async fn test_cancel_already_done() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - mock.queue_response(ok_response(1)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Consume the entire stream - while let Some(result) = stream.next_chunk().await { - result.unwrap(); - } - - // Cancel on a completed stream is a no-op - stream.cancel(Duration::from_secs(2)).await.unwrap(); - - // cancel_transfer should NOT have been called (stream was already done) - assert!(mock.get_cancel_calls().is_empty()); - } - - #[tokio::test] - async fn test_cancel_calls_transport_cancel_transfer() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3, 4, 5]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Read one chunk - stream.next_chunk().await.unwrap().unwrap(); - - // Cancel mid-stream — should delegate to transport.cancel_transfer() - stream.cancel(Duration::from_secs(2)).await.unwrap(); - - // Verify cancel_transfer was called with the correct transaction ID - let cancel_calls = mock.get_cancel_calls(); - assert_eq!(cancel_calls, vec![1]); // tx_id=1 (first operation after OpenSession) - } - - #[tokio::test] - async fn test_cancel_propagates_transport_error() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - - // Queue a cancel failure - mock.queue_cancel_result(Err(crate::Error::Disconnected)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Read one chunk - stream.next_chunk().await.unwrap().unwrap(); - - // Cancel should propagate the transport error - let result = stream.cancel(Duration::from_secs(2)).await; - assert!(result.is_err()); - - // Stream should be marked done even on error - assert!(stream.done); - } - - #[tokio::test] - async fn test_cancel_marks_stream_done() { - let (transport, mock) = mock_transport(); - mock.queue_response(ok_response(0)); // OpenSession - - let file_data = vec![1, 2, 3]; - mock.queue_response(data_container(1, OperationCode::GetObject, &file_data)); - - let session = Arc::new(PtpSession::open(transport, 1).await.unwrap()); - let mut stream = session.get_object_stream(ObjectHandle(1)).await.unwrap(); - - // Read one chunk - stream.next_chunk().await.unwrap().unwrap(); - - // Cancel - stream.cancel(Duration::from_secs(2)).await.unwrap(); - - // Stream should be done — next_chunk returns None - assert!(stream.next_chunk().await.is_none()); - - // Second cancel is a no-op (no additional cancel_transfer call) - stream.cancel(Duration::from_secs(2)).await.unwrap(); - assert_eq!(mock.get_cancel_calls().len(), 1); - } -} diff --git a/vendor/mtp-rs/src/ptp/test_utils.rs b/vendor/mtp-rs/src/ptp/test_utils.rs deleted file mode 100644 index 581a575..0000000 --- a/vendor/mtp-rs/src/ptp/test_utils.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Test utilities and macros for PTP module tests. -//! -//! This module provides macros for common test patterns, reducing boilerplate -//! across the codebase. - -/// Generate a proptest that verifies `Type::from_bytes` doesn't panic on arbitrary input. -/// -/// This is the most common fuzz test pattern: feed random bytes to a parser and -/// verify it handles them gracefully (returns Ok or Err, but never panics). -/// -/// # Usage -/// -/// ```ignore -/// fuzz_bytes!(data_container, DataContainer, 100); -/// ``` -/// -/// Expands to a proptest named `fuzz_data_container` that feeds 0-100 random bytes -/// to `DataContainer::from_bytes`. -#[cfg(test)] -#[macro_export] -macro_rules! fuzz_bytes { - ($test_name:ident, $type:ty, $max_size:expr) => { - proptest::proptest! { - #[test] - fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { - let _ = <$type>::from_bytes(&bytes); - } - } - }; -} - -/// Generate a proptest that verifies a function doesn't panic on arbitrary byte input. -/// -/// Similar to `fuzz_bytes!` but for standalone functions instead of methods. -/// -/// # Usage -/// -/// ```ignore -/// fuzz_bytes_fn!(fuzz_container_type, container_type, 100); -/// ``` -#[cfg(test)] -#[macro_export] -macro_rules! fuzz_bytes_fn { - ($test_name:ident, $fn_name:ident, $max_size:expr) => { - proptest::proptest! { - #[test] - fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { - let _ = $fn_name(&bytes); - } - } - }; -} - -/// Generate proptest for types with two-argument `from_bytes(bytes, type_hint)`. -/// -/// Used for parsers that need a type discriminator, like PropertyValue or PropertyRange. -/// -/// # Usage -/// -/// ```ignore -/// fuzz_bytes_with_type!( -/// fuzz_property_value, -/// PropertyValue, -/// PropertyDataType, -/// [Int8, Uint8, Int16, Uint16, Int32, Uint32, Int64, Uint64, String], -/// 20 -/// ); -/// ``` -#[cfg(test)] -#[macro_export] -macro_rules! fuzz_bytes_with_type { - ($test_name:ident, $type:ty, $hint_type:ty, [$($variant:ident),+ $(,)?], $max_size:expr) => { - proptest::proptest! { - #[test] - fn $test_name(bytes in proptest::collection::vec(proptest::arbitrary::any::(), 0..$max_size)) { - $( - let _ = <$type>::from_bytes(&bytes, <$hint_type>::$variant); - )+ - } - } - }; -} diff --git a/vendor/mtp-rs/src/ptp/types/device.rs b/vendor/mtp-rs/src/ptp/types/device.rs deleted file mode 100644 index e3cc184..0000000 --- a/vendor/mtp-rs/src/ptp/types/device.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! Device and storage information types for MTP/PTP. -//! -//! This module contains: -//! - [`DeviceInfo`]: Device capabilities and identification -//! - [`StorageInfo`]: Storage characteristics and capacity - -use super::storage::{AccessCapability, FilesystemType, StorageType}; -use crate::ptp::pack::{unpack_string, unpack_u16, unpack_u16_array, unpack_u32, unpack_u64}; -use crate::ptp::{EventCode, ObjectFormatCode, OperationCode}; - -// --- DeviceInfo Structure --- - -/// Device information returned by GetDeviceInfo. -/// -/// Contains device capabilities, manufacturer info, and supported operations. -#[derive(Debug, Clone, Default)] -pub struct DeviceInfo { - /// PTP standard version (e.g., 100 = v1.00). - pub standard_version: u16, - /// Vendor extension ID (0 = no extension). - pub vendor_extension_id: u32, - /// Vendor extension version. - pub vendor_extension_version: u16, - /// Vendor extension description. - pub vendor_extension_desc: String, - /// Functional mode (0 = standard). - pub functional_mode: u16, - /// Operations supported by the device. - pub operations_supported: Vec, - /// Events supported by the device. - pub events_supported: Vec, - /// Device properties supported. - pub device_properties_supported: Vec, - /// Object formats the device can capture/create. - pub capture_formats: Vec, - /// Object formats the device can play/display. - pub playback_formats: Vec, - /// Manufacturer name. - pub manufacturer: String, - /// Device model name. - pub model: String, - /// Device version string. - pub device_version: String, - /// Device serial number. - pub serial_number: String, -} - -impl DeviceInfo { - /// Parse DeviceInfo from a byte buffer. - /// - /// The buffer should contain the DeviceInfo dataset as returned by GetDeviceInfo. - pub fn from_bytes(buf: &[u8]) -> Result { - let mut offset = 0; - - // 1. StandardVersion (u16) - let standard_version = unpack_u16(&buf[offset..])?; - offset += 2; - - // 2. VendorExtensionID (u32) - let vendor_extension_id = unpack_u32(&buf[offset..])?; - offset += 4; - - // 3. VendorExtensionVersion (u16) - let vendor_extension_version = unpack_u16(&buf[offset..])?; - offset += 2; - - // 4. VendorExtensionDesc (string) - let (vendor_extension_desc, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 5. FunctionalMode (u16) - let functional_mode = unpack_u16(&buf[offset..])?; - offset += 2; - - // 6. OperationsSupported (u16 array) - let (ops_raw, consumed) = unpack_u16_array(&buf[offset..])?; - let operations_supported: Vec = - ops_raw.into_iter().map(OperationCode::from).collect(); - offset += consumed; - - // 7. EventsSupported (u16 array) - let (events_raw, consumed) = unpack_u16_array(&buf[offset..])?; - let events_supported: Vec = - events_raw.into_iter().map(EventCode::from).collect(); - offset += consumed; - - // 8. DevicePropertiesSupported (u16 array) - let (device_properties_supported, consumed) = unpack_u16_array(&buf[offset..])?; - offset += consumed; - - // 9. CaptureFormats (u16 array) - let (capture_raw, consumed) = unpack_u16_array(&buf[offset..])?; - let capture_formats: Vec = capture_raw - .into_iter() - .map(ObjectFormatCode::from) - .collect(); - offset += consumed; - - // 10. PlaybackFormats (u16 array) - let (playback_raw, consumed) = unpack_u16_array(&buf[offset..])?; - let playback_formats: Vec = playback_raw - .into_iter() - .map(ObjectFormatCode::from) - .collect(); - offset += consumed; - - // 11. Manufacturer (string) - let (manufacturer, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 12. Model (string) - let (model, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 13. DeviceVersion (string) - let (device_version, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 14. SerialNumber (string) - let (serial_number, _) = unpack_string(&buf[offset..])?; - - Ok(DeviceInfo { - standard_version, - vendor_extension_id, - vendor_extension_version, - vendor_extension_desc, - functional_mode, - operations_supported, - events_supported, - device_properties_supported, - capture_formats, - playback_formats, - manufacturer, - model, - device_version, - serial_number, - }) - } - - /// Check if the device supports a specific operation. - /// - /// # Arguments - /// - /// * `operation` - The operation code to check - /// - /// # Returns - /// - /// Returns true if the operation is in the device's supported operations list. - #[must_use] - pub fn supports_operation(&self, operation: OperationCode) -> bool { - self.operations_supported.contains(&operation) - } - - /// Check if the device supports renaming objects. - /// - /// This checks for support of the SetObjectPropValue operation (0x9804), - /// which is required to rename files and folders via the ObjectFileName property. - /// - /// # Returns - /// - /// Returns true if the device advertises SetObjectPropValue support. - #[must_use] - pub fn supports_rename(&self) -> bool { - self.supports_operation(OperationCode::SetObjectPropValue) - } -} - -// --- StorageInfo Structure --- - -/// Storage information returned by GetStorageInfo. -/// -/// Contains storage capacity, type, and access information. -#[derive(Debug, Clone, Default)] -pub struct StorageInfo { - /// Type of storage medium. - pub storage_type: StorageType, - /// Type of filesystem. - pub filesystem_type: FilesystemType, - /// Access capability. - pub access_capability: AccessCapability, - /// Maximum storage capacity in bytes. - pub max_capacity: u64, - /// Free space in bytes. - pub free_space_bytes: u64, - /// Free space in number of objects (0xFFFFFFFF if unknown). - pub free_space_objects: u32, - /// Storage description string. - pub description: String, - /// Volume identifier/label. - pub volume_identifier: String, -} - -impl StorageInfo { - /// Parse StorageInfo from a byte buffer. - /// - /// The buffer should contain the StorageInfo dataset as returned by GetStorageInfo. - pub fn from_bytes(buf: &[u8]) -> Result { - let mut offset = 0; - - // 1. StorageType (u16) - let storage_type = StorageType::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 2. FilesystemType (u16) - let filesystem_type = FilesystemType::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 3. AccessCapability (u16) - let access_capability = AccessCapability::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 4. MaxCapacity (u64) - let max_capacity = unpack_u64(&buf[offset..])?; - offset += 8; - - // 5. FreeSpaceInBytes (u64) - let free_space_bytes = unpack_u64(&buf[offset..])?; - offset += 8; - - // 6. FreeSpaceInObjects (u32) - let free_space_objects = unpack_u32(&buf[offset..])?; - offset += 4; - - // 7. StorageDescription (string) - let (description, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 8. VolumeIdentifier (string) - let (volume_identifier, _) = unpack_string(&buf[offset..])?; - - Ok(StorageInfo { - storage_type, - filesystem_type, - access_capability, - max_capacity, - free_space_bytes, - free_space_objects, - description, - volume_identifier, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::pack::{pack_string, pack_u16, pack_u16_array, pack_u32}; - - // --- DeviceInfo Tests --- - - fn build_minimal_device_info_bytes() -> Vec { - let mut buf = Vec::new(); - - // StandardVersion: 100 (v1.00) - buf.extend_from_slice(&pack_u16(100)); - // VendorExtensionID: 0 - buf.extend_from_slice(&pack_u32(0)); - // VendorExtensionVersion: 0 - buf.extend_from_slice(&pack_u16(0)); - // VendorExtensionDesc: empty string - buf.push(0x00); - // FunctionalMode: 0 - buf.extend_from_slice(&pack_u16(0)); - // OperationsSupported: empty array - buf.extend_from_slice(&pack_u16_array(&[])); - // EventsSupported: empty array - buf.extend_from_slice(&pack_u16_array(&[])); - // DevicePropertiesSupported: empty array - buf.extend_from_slice(&pack_u16_array(&[])); - // CaptureFormats: empty array - buf.extend_from_slice(&pack_u16_array(&[])); - // PlaybackFormats: empty array - buf.extend_from_slice(&pack_u16_array(&[])); - // Manufacturer: empty string - buf.push(0x00); - // Model: empty string - buf.push(0x00); - // DeviceVersion: empty string - buf.push(0x00); - // SerialNumber: empty string - buf.push(0x00); - - buf - } - - #[test] - fn device_info_parse_minimal() { - let buf = build_minimal_device_info_bytes(); - let info = DeviceInfo::from_bytes(&buf).unwrap(); - - assert_eq!(info.standard_version, 100); - assert_eq!(info.vendor_extension_id, 0); - assert_eq!(info.vendor_extension_version, 0); - assert_eq!(info.vendor_extension_desc, ""); - assert_eq!(info.functional_mode, 0); - assert!(info.operations_supported.is_empty()); - assert!(info.events_supported.is_empty()); - assert!(info.device_properties_supported.is_empty()); - assert!(info.capture_formats.is_empty()); - assert!(info.playback_formats.is_empty()); - assert_eq!(info.manufacturer, ""); - assert_eq!(info.model, ""); - assert_eq!(info.device_version, ""); - assert_eq!(info.serial_number, ""); - } - - fn build_full_device_info_bytes() -> Vec { - let mut buf = Vec::new(); - - // StandardVersion: 100 (v1.00) - buf.extend_from_slice(&pack_u16(100)); - // VendorExtensionID: 0x00000006 (Microsoft) - buf.extend_from_slice(&pack_u32(6)); - // VendorExtensionVersion: 100 - buf.extend_from_slice(&pack_u16(100)); - // VendorExtensionDesc: "microsoft.com: 1.0" - buf.extend_from_slice(&pack_string("microsoft.com: 1.0")); - // FunctionalMode: 0 - buf.extend_from_slice(&pack_u16(0)); - // OperationsSupported: [GetDeviceInfo, OpenSession, CloseSession] - buf.extend_from_slice(&pack_u16_array(&[0x1001, 0x1002, 0x1003])); - // EventsSupported: [ObjectAdded, ObjectRemoved] - buf.extend_from_slice(&pack_u16_array(&[0x4002, 0x4003])); - // DevicePropertiesSupported: [0x5001, 0x5002] - buf.extend_from_slice(&pack_u16_array(&[0x5001, 0x5002])); - // CaptureFormats: [JPEG] - buf.extend_from_slice(&pack_u16_array(&[0x3801])); - // PlaybackFormats: [JPEG, MP3] - buf.extend_from_slice(&pack_u16_array(&[0x3801, 0x3009])); - // Manufacturer: "Test Manufacturer" - buf.extend_from_slice(&pack_string("Test Manufacturer")); - // Model: "Test Model" - buf.extend_from_slice(&pack_string("Test Model")); - // DeviceVersion: "1.0.0" - buf.extend_from_slice(&pack_string("1.0.0")); - // SerialNumber: "ABC123" - buf.extend_from_slice(&pack_string("ABC123")); - - buf - } - - #[test] - fn device_info_parse_full() { - let buf = build_full_device_info_bytes(); - let info = DeviceInfo::from_bytes(&buf).unwrap(); - - assert_eq!(info.standard_version, 100); - assert_eq!(info.vendor_extension_id, 6); - assert_eq!(info.vendor_extension_version, 100); - assert_eq!(info.vendor_extension_desc, "microsoft.com: 1.0"); - assert_eq!(info.functional_mode, 0); - - assert_eq!(info.operations_supported.len(), 3); - assert_eq!(info.operations_supported[0], OperationCode::GetDeviceInfo); - assert_eq!(info.operations_supported[1], OperationCode::OpenSession); - assert_eq!(info.operations_supported[2], OperationCode::CloseSession); - - assert_eq!(info.events_supported.len(), 2); - assert_eq!(info.events_supported[0], EventCode::ObjectAdded); - assert_eq!(info.events_supported[1], EventCode::ObjectRemoved); - - assert_eq!(info.device_properties_supported, vec![0x5001, 0x5002]); - - assert_eq!(info.capture_formats.len(), 1); - assert_eq!(info.capture_formats[0], ObjectFormatCode::Jpeg); - - assert_eq!(info.playback_formats.len(), 2); - assert_eq!(info.playback_formats[0], ObjectFormatCode::Jpeg); - assert_eq!(info.playback_formats[1], ObjectFormatCode::Mp3); - - assert_eq!(info.manufacturer, "Test Manufacturer"); - assert_eq!(info.model, "Test Model"); - assert_eq!(info.device_version, "1.0.0"); - assert_eq!(info.serial_number, "ABC123"); - } - - #[test] - fn device_info_parse_insufficient_bytes() { - let buf = vec![0x00, 0x01]; // Only 2 bytes - assert!(DeviceInfo::from_bytes(&buf).is_err()); - } - - // --- StorageInfo Tests --- - - fn build_storage_info_bytes() -> Vec { - let mut buf = Vec::new(); - - // StorageType: RemovableRam (4) - buf.extend_from_slice(&pack_u16(4)); - // FilesystemType: GenericHierarchical (2) - buf.extend_from_slice(&pack_u16(2)); - // AccessCapability: ReadWrite (0) - buf.extend_from_slice(&pack_u16(0)); - // MaxCapacity: 32GB - buf.extend_from_slice(&32_000_000_000u64.to_le_bytes()); - // FreeSpaceInBytes: 16GB - buf.extend_from_slice(&16_000_000_000u64.to_le_bytes()); - // FreeSpaceInObjects: 0xFFFFFFFF (unknown) - buf.extend_from_slice(&pack_u32(0xFFFFFFFF)); - // StorageDescription: "SD Card" - buf.extend_from_slice(&pack_string("SD Card")); - // VolumeIdentifier: "VOL001" - buf.extend_from_slice(&pack_string("VOL001")); - - buf - } - - #[test] - fn storage_info_parse() { - let buf = build_storage_info_bytes(); - let info = StorageInfo::from_bytes(&buf).unwrap(); - - assert_eq!(info.storage_type, StorageType::RemovableRam); - assert_eq!(info.filesystem_type, FilesystemType::GenericHierarchical); - assert_eq!(info.access_capability, AccessCapability::ReadWrite); - assert_eq!(info.max_capacity, 32_000_000_000); - assert_eq!(info.free_space_bytes, 16_000_000_000); - assert_eq!(info.free_space_objects, 0xFFFFFFFF); - assert_eq!(info.description, "SD Card"); - assert_eq!(info.volume_identifier, "VOL001"); - } - - #[test] - fn storage_info_parse_insufficient_bytes() { - let buf = vec![0x00; 10]; // Not enough bytes - assert!(StorageInfo::from_bytes(&buf).is_err()); - } - - // --- DeviceInfo capability tests --- - - #[test] - fn device_info_supports_operation() { - let info = DeviceInfo { - operations_supported: vec![ - OperationCode::GetDeviceInfo, - OperationCode::OpenSession, - OperationCode::SetObjectPropValue, - ], - ..Default::default() - }; - - assert!(info.supports_operation(OperationCode::GetDeviceInfo)); - assert!(info.supports_operation(OperationCode::OpenSession)); - assert!(info.supports_operation(OperationCode::SetObjectPropValue)); - assert!(!info.supports_operation(OperationCode::DeleteObject)); - assert!(!info.supports_operation(OperationCode::GetObjectPropValue)); - } - - #[test] - fn device_info_supports_rename_true() { - let info = DeviceInfo { - operations_supported: vec![ - OperationCode::GetDeviceInfo, - OperationCode::SetObjectPropValue, // Required for rename - ], - ..Default::default() - }; - - assert!(info.supports_rename()); - } - - #[test] - fn device_info_supports_rename_false() { - let info = DeviceInfo { - operations_supported: vec![ - OperationCode::GetDeviceInfo, - OperationCode::GetObjectPropValue, // Has Get but not Set - ], - ..Default::default() - }; - - assert!(!info.supports_rename()); - } - - #[test] - fn device_info_supports_rename_empty() { - let info = DeviceInfo::default(); - assert!(!info.supports_rename()); - } - - // Fuzz tests using shared macros - verify parsers don't panic on arbitrary input - crate::fuzz_bytes!(fuzz_device_info, DeviceInfo, 200); - crate::fuzz_bytes!(fuzz_storage_info, StorageInfo, 100); - - #[test] - fn device_info_minimum_valid() { - // DeviceInfo needs at minimum: u16 + u32 + u16 + string + u16 + 5 arrays + 4 strings - // This is a lot of bytes. Test that small buffers fail gracefully. - assert!(DeviceInfo::from_bytes(&[]).is_err()); - assert!(DeviceInfo::from_bytes(&[0; 1]).is_err()); - assert!(DeviceInfo::from_bytes(&[0; 7]).is_err()); - assert!(DeviceInfo::from_bytes(&[0; 8]).is_err()); // Need at least string data after first fields - } - - #[test] - fn storage_info_minimum_valid() { - // StorageInfo needs: 3 * u16 + 2 * u64 + u32 + 2 strings = 26 bytes minimum + string data - assert!(StorageInfo::from_bytes(&[]).is_err()); - assert!(StorageInfo::from_bytes(&[0; 25]).is_err()); - assert!(StorageInfo::from_bytes(&[0; 26]).is_err()); // Still need string data - } - - #[test] - fn storage_info_max_capacity() { - let mut buf = build_storage_info_bytes(); - // Replace MaxCapacity field (bytes 6-13, after 3 u16s = 6 bytes) - let max_bytes = u64::MAX.to_le_bytes(); - buf[6..14].copy_from_slice(&max_bytes); - - let info = StorageInfo::from_bytes(&buf).unwrap(); - assert_eq!(info.max_capacity, u64::MAX); - } -} diff --git a/vendor/mtp-rs/src/ptp/types/mod.rs b/vendor/mtp-rs/src/ptp/types/mod.rs deleted file mode 100644 index 9ff926c..0000000 --- a/vendor/mtp-rs/src/ptp/types/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! MTP/PTP data structures for device, storage, and object information. -//! -//! This module provides high-level structures for parsing protocol responses: -//! - [`DeviceInfo`]: Device capabilities and identification -//! - [`StorageInfo`]: Storage characteristics and capacity -//! - [`ObjectInfo`]: File/folder metadata -//! - [`DevicePropDesc`]: Device property descriptors -//! - [`PropertyValue`]: Property values of various types - -mod device; -mod objects; -mod properties; -mod storage; - -// Re-export all public types for backward compatibility -pub use device::{DeviceInfo, StorageInfo}; -pub use objects::ObjectInfo; -pub use properties::{DevicePropDesc, PropertyFormType, PropertyRange, PropertyValue}; -pub use storage::{ - AccessCapability, AssociationType, FilesystemType, ProtectionStatus, StorageType, -}; diff --git a/vendor/mtp-rs/src/ptp/types/objects.rs b/vendor/mtp-rs/src/ptp/types/objects.rs deleted file mode 100644 index 21f57c3..0000000 --- a/vendor/mtp-rs/src/ptp/types/objects.rs +++ /dev/null @@ -1,595 +0,0 @@ -//! Object-related types for MTP/PTP. -//! -//! This module contains the [`ObjectInfo`] structure for file/folder metadata. - -use super::storage::{AssociationType, ProtectionStatus}; -use crate::ptp::pack::{ - pack_datetime, pack_string, pack_u16, pack_u32, unpack_datetime, unpack_string, unpack_u16, - unpack_u32, DateTime, -}; -use crate::ptp::{ObjectFormatCode, ObjectHandle, StorageId}; - -// --- ObjectInfo Structure --- - -/// Object information returned by GetObjectInfo. -/// -/// Contains file/folder metadata including name, size, timestamps, and hierarchy info. -#[derive(Debug, Clone, Default)] -pub struct ObjectInfo { - /// Object handle (set after parsing, not part of protocol data). - pub handle: ObjectHandle, - /// Storage containing this object. - pub storage_id: StorageId, - /// Object format code. - pub format: ObjectFormatCode, - /// Protection status. - pub protection_status: ProtectionStatus, - /// Object size in bytes. - /// - /// The standard `ObjectInfo` dataset encodes size as u32 which saturates at - /// `u32::MAX` for files larger than 4 GB. When this happens and the dataset - /// is obtained via high-level APIs like [`Storage::get_object_info`], - /// [`Storage::list_objects`], or [`PtpSession::get_object_info_full`], the - /// real u64 size is auto-resolved via `GetObjectPropValue(ObjectSize)`. - /// - /// [`Storage::get_object_info`]: crate::mtp::Storage::get_object_info - /// [`Storage::list_objects`]: crate::mtp::Storage::list_objects - /// [`PtpSession::get_object_info_full`]: crate::ptp::PtpSession::get_object_info_full - pub size: u64, - /// Thumbnail format. - pub thumb_format: ObjectFormatCode, - /// Thumbnail size in bytes. - pub thumb_size: u32, - /// Thumbnail width in pixels. - pub thumb_width: u32, - /// Thumbnail height in pixels. - pub thumb_height: u32, - /// Image width in pixels. - pub image_width: u32, - /// Image height in pixels. - pub image_height: u32, - /// Image bit depth. - pub image_bit_depth: u32, - /// Parent object handle (ROOT for root-level objects). - pub parent: ObjectHandle, - /// Association type (folder type). - pub association_type: AssociationType, - /// Association description. - pub association_desc: u32, - /// Sequence number. - pub sequence_number: u32, - /// Filename. - pub filename: String, - /// Creation timestamp. - pub created: Option, - /// Modification timestamp. - pub modified: Option, - /// Keywords string. - pub keywords: String, -} - -impl ObjectInfo { - /// Parse ObjectInfo from a byte buffer. - /// - /// The buffer should contain the ObjectInfo dataset as returned by GetObjectInfo. - pub fn from_bytes(buf: &[u8]) -> Result { - let mut offset = 0; - - // 1. StorageID (u32) - let storage_id = StorageId(unpack_u32(&buf[offset..])?); - offset += 4; - - // 2. ObjectFormat (u16) - let format = ObjectFormatCode::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 3. ProtectionStatus (u16) - let protection_status = ProtectionStatus::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 4. ObjectCompressedSize (u32) - stored as u64, but protocol uses u32 - let size = unpack_u32(&buf[offset..])? as u64; - offset += 4; - - // 5. ThumbFormat (u16) - let thumb_format = ObjectFormatCode::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 6. ThumbCompressedSize (u32) - let thumb_size = unpack_u32(&buf[offset..])?; - offset += 4; - - // 7. ThumbPixWidth (u32) - let thumb_width = unpack_u32(&buf[offset..])?; - offset += 4; - - // 8. ThumbPixHeight (u32) - let thumb_height = unpack_u32(&buf[offset..])?; - offset += 4; - - // 9. ImagePixWidth (u32) - let image_width = unpack_u32(&buf[offset..])?; - offset += 4; - - // 10. ImagePixHeight (u32) - let image_height = unpack_u32(&buf[offset..])?; - offset += 4; - - // 11. ImageBitDepth (u32) - let image_bit_depth = unpack_u32(&buf[offset..])?; - offset += 4; - - // 12. ParentObject (u32) - let parent = ObjectHandle(unpack_u32(&buf[offset..])?); - offset += 4; - - // 13. AssociationType (u16) - let association_type = AssociationType::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 14. AssociationDesc (u32) - let association_desc = unpack_u32(&buf[offset..])?; - offset += 4; - - // 15. SequenceNumber (u32) - let sequence_number = unpack_u32(&buf[offset..])?; - offset += 4; - - // 16. Filename (string) - let (filename, consumed) = unpack_string(&buf[offset..])?; - offset += consumed; - - // 17. DateCreated (datetime string) - let (created, consumed) = unpack_datetime(&buf[offset..])?; - offset += consumed; - - // 18. DateModified (datetime string) - let (modified, consumed) = unpack_datetime(&buf[offset..])?; - offset += consumed; - - // 19. Keywords (string) - let (keywords, _consumed) = unpack_string(&buf[offset..])?; - - Ok(ObjectInfo { - handle: ObjectHandle::default(), // Set by caller after parsing - storage_id, - format, - protection_status, - size, - thumb_format, - thumb_size, - thumb_width, - thumb_height, - image_width, - image_height, - image_bit_depth, - parent, - association_type, - association_desc, - sequence_number, - filename, - created, - modified, - keywords, - }) - } - - /// Serialize ObjectInfo to a byte buffer. - /// - /// Used for SendObjectInfo operation. - /// - /// Returns an error if the created or modified DateTime contains invalid values. - pub fn to_bytes(&self) -> Result, crate::Error> { - let mut buf = Vec::new(); - - // 1. StorageID (u32) - buf.extend_from_slice(&pack_u32(self.storage_id.0)); - - // 2. ObjectFormat (u16) - buf.extend_from_slice(&pack_u16(self.format.into())); - - // 3. ProtectionStatus (u16) - buf.extend_from_slice(&pack_u16(self.protection_status.into())); - - // 4. ObjectCompressedSize (u32) - cap at u32::MAX for >4GB files - let size_u32 = if self.size > u32::MAX as u64 { - u32::MAX - } else { - self.size as u32 - }; - buf.extend_from_slice(&pack_u32(size_u32)); - - // 5. ThumbFormat (u16) - buf.extend_from_slice(&pack_u16(self.thumb_format.into())); - - // 6. ThumbCompressedSize (u32) - buf.extend_from_slice(&pack_u32(self.thumb_size)); - - // 7. ThumbPixWidth (u32) - buf.extend_from_slice(&pack_u32(self.thumb_width)); - - // 8. ThumbPixHeight (u32) - buf.extend_from_slice(&pack_u32(self.thumb_height)); - - // 9. ImagePixWidth (u32) - buf.extend_from_slice(&pack_u32(self.image_width)); - - // 10. ImagePixHeight (u32) - buf.extend_from_slice(&pack_u32(self.image_height)); - - // 11. ImageBitDepth (u32) - buf.extend_from_slice(&pack_u32(self.image_bit_depth)); - - // 12. ParentObject (u32) - buf.extend_from_slice(&pack_u32(self.parent.0)); - - // 13. AssociationType (u16) - buf.extend_from_slice(&pack_u16(self.association_type.into())); - - // 14. AssociationDesc (u32) - buf.extend_from_slice(&pack_u32(self.association_desc)); - - // 15. SequenceNumber (u32) - buf.extend_from_slice(&pack_u32(self.sequence_number)); - - // 16. Filename (string) - buf.extend_from_slice(&pack_string(&self.filename)); - - // 17. DateCreated (datetime string) - if let Some(dt) = &self.created { - buf.extend_from_slice(&pack_datetime(dt)?); - } else { - buf.push(0x00); // Empty string - } - - // 18. DateModified (datetime string) - if let Some(dt) = &self.modified { - buf.extend_from_slice(&pack_datetime(dt)?); - } else { - buf.push(0x00); // Empty string - } - - // 19. Keywords (string) - buf.extend_from_slice(&pack_string(&self.keywords)); - - Ok(buf) - } - - /// Check if this object is a folder. - /// - /// Returns true if the format is Association or the association type is GenericFolder. - #[must_use] - pub fn is_folder(&self) -> bool { - self.format == ObjectFormatCode::Association - || self.association_type == AssociationType::GenericFolder - } - - /// Check if this object is a file. - /// - /// Returns true if this is not a folder. - #[must_use] - pub fn is_file(&self) -> bool { - !self.is_folder() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::pack::{pack_datetime, pack_string, pack_u16, pack_u32, DateTime}; - - // --- ObjectInfo Tests --- - - fn build_file_object_info_bytes() -> Vec { - let mut buf = Vec::new(); - - // StorageID: 0x00010001 - buf.extend_from_slice(&pack_u32(0x00010001)); - // ObjectFormat: JPEG (0x3801) - buf.extend_from_slice(&pack_u16(0x3801)); - // ProtectionStatus: None (0) - buf.extend_from_slice(&pack_u16(0)); - // ObjectCompressedSize: 1024 bytes - buf.extend_from_slice(&pack_u32(1024)); - // ThumbFormat: JPEG (0x3801) - buf.extend_from_slice(&pack_u16(0x3801)); - // ThumbCompressedSize: 512 - buf.extend_from_slice(&pack_u32(512)); - // ThumbPixWidth: 160 - buf.extend_from_slice(&pack_u32(160)); - // ThumbPixHeight: 120 - buf.extend_from_slice(&pack_u32(120)); - // ImagePixWidth: 1920 - buf.extend_from_slice(&pack_u32(1920)); - // ImagePixHeight: 1080 - buf.extend_from_slice(&pack_u32(1080)); - // ImageBitDepth: 24 - buf.extend_from_slice(&pack_u32(24)); - // ParentObject: 0x00000005 - buf.extend_from_slice(&pack_u32(5)); - // AssociationType: None (0) - buf.extend_from_slice(&pack_u16(0)); - // AssociationDesc: 0 - buf.extend_from_slice(&pack_u32(0)); - // SequenceNumber: 1 - buf.extend_from_slice(&pack_u32(1)); - // Filename: "photo.jpg" - buf.extend_from_slice(&pack_string("photo.jpg")); - // DateCreated: "20240315T143022" - buf.extend_from_slice( - &pack_datetime(&DateTime { - year: 2024, - month: 3, - day: 15, - hour: 14, - minute: 30, - second: 22, - }) - .unwrap(), - ); - // DateModified: "20240316T090000" - buf.extend_from_slice( - &pack_datetime(&DateTime { - year: 2024, - month: 3, - day: 16, - hour: 9, - minute: 0, - second: 0, - }) - .unwrap(), - ); - // Keywords: "" - buf.push(0x00); - - buf - } - - #[test] - fn object_info_parse_file() { - let buf = build_file_object_info_bytes(); - let info = ObjectInfo::from_bytes(&buf).unwrap(); - - assert_eq!(info.storage_id, StorageId(0x00010001)); - assert_eq!(info.format, ObjectFormatCode::Jpeg); - assert_eq!(info.protection_status, ProtectionStatus::None); - assert_eq!(info.size, 1024); - assert_eq!(info.thumb_format, ObjectFormatCode::Jpeg); - assert_eq!(info.thumb_size, 512); - assert_eq!(info.thumb_width, 160); - assert_eq!(info.thumb_height, 120); - assert_eq!(info.image_width, 1920); - assert_eq!(info.image_height, 1080); - assert_eq!(info.image_bit_depth, 24); - assert_eq!(info.parent, ObjectHandle(5)); - assert_eq!(info.association_type, AssociationType::None); - assert_eq!(info.association_desc, 0); - assert_eq!(info.sequence_number, 1); - assert_eq!(info.filename, "photo.jpg"); - assert!(info.created.is_some()); - let created = info.created.unwrap(); - assert_eq!(created.year, 2024); - assert_eq!(created.month, 3); - assert_eq!(created.day, 15); - assert!(info.modified.is_some()); - assert_eq!(info.keywords, ""); - - assert!(info.is_file()); - assert!(!info.is_folder()); - } - - fn build_folder_object_info_bytes() -> Vec { - let mut buf = Vec::new(); - - // StorageID: 0x00010001 - buf.extend_from_slice(&pack_u32(0x00010001)); - // ObjectFormat: Association (0x3001) - buf.extend_from_slice(&pack_u16(0x3001)); - // ProtectionStatus: None (0) - buf.extend_from_slice(&pack_u16(0)); - // ObjectCompressedSize: 0 - buf.extend_from_slice(&pack_u32(0)); - // ThumbFormat: Undefined (0x3000) - buf.extend_from_slice(&pack_u16(0x3000)); - // ThumbCompressedSize: 0 - buf.extend_from_slice(&pack_u32(0)); - // ThumbPixWidth: 0 - buf.extend_from_slice(&pack_u32(0)); - // ThumbPixHeight: 0 - buf.extend_from_slice(&pack_u32(0)); - // ImagePixWidth: 0 - buf.extend_from_slice(&pack_u32(0)); - // ImagePixHeight: 0 - buf.extend_from_slice(&pack_u32(0)); - // ImageBitDepth: 0 - buf.extend_from_slice(&pack_u32(0)); - // ParentObject: ROOT (0) - buf.extend_from_slice(&pack_u32(0)); - // AssociationType: GenericFolder (1) - buf.extend_from_slice(&pack_u16(1)); - // AssociationDesc: 0 - buf.extend_from_slice(&pack_u32(0)); - // SequenceNumber: 0 - buf.extend_from_slice(&pack_u32(0)); - // Filename: "DCIM" - buf.extend_from_slice(&pack_string("DCIM")); - // DateCreated: empty - buf.push(0x00); - // DateModified: empty - buf.push(0x00); - // Keywords: "" - buf.push(0x00); - - buf - } - - #[test] - fn object_info_parse_folder() { - let buf = build_folder_object_info_bytes(); - let info = ObjectInfo::from_bytes(&buf).unwrap(); - - assert_eq!(info.format, ObjectFormatCode::Association); - assert_eq!(info.association_type, AssociationType::GenericFolder); - assert_eq!(info.filename, "DCIM"); - assert_eq!(info.parent, ObjectHandle::ROOT); - assert!(info.created.is_none()); - assert!(info.modified.is_none()); - - assert!(info.is_folder()); - assert!(!info.is_file()); - } - - #[test] - fn object_info_to_bytes_roundtrip() { - let original = ObjectInfo { - handle: ObjectHandle(42), - storage_id: StorageId(0x00010001), - format: ObjectFormatCode::Jpeg, - protection_status: ProtectionStatus::None, - size: 2048, - thumb_format: ObjectFormatCode::Jpeg, - thumb_size: 256, - thumb_width: 80, - thumb_height: 60, - image_width: 800, - image_height: 600, - image_bit_depth: 24, - parent: ObjectHandle(10), - association_type: AssociationType::None, - association_desc: 0, - sequence_number: 5, - filename: "test.jpg".to_string(), - created: Some(DateTime { - year: 2024, - month: 6, - day: 15, - hour: 10, - minute: 30, - second: 0, - }), - modified: Some(DateTime { - year: 2024, - month: 6, - day: 16, - hour: 11, - minute: 45, - second: 30, - }), - keywords: "test,photo".to_string(), - }; - - let bytes = original.to_bytes().unwrap(); - let parsed = ObjectInfo::from_bytes(&bytes).unwrap(); - - assert_eq!(parsed.storage_id, original.storage_id); - assert_eq!(parsed.format, original.format); - assert_eq!(parsed.protection_status, original.protection_status); - assert_eq!(parsed.size, original.size); - assert_eq!(parsed.thumb_format, original.thumb_format); - assert_eq!(parsed.thumb_size, original.thumb_size); - assert_eq!(parsed.thumb_width, original.thumb_width); - assert_eq!(parsed.thumb_height, original.thumb_height); - assert_eq!(parsed.image_width, original.image_width); - assert_eq!(parsed.image_height, original.image_height); - assert_eq!(parsed.image_bit_depth, original.image_bit_depth); - assert_eq!(parsed.parent, original.parent); - assert_eq!(parsed.association_type, original.association_type); - assert_eq!(parsed.association_desc, original.association_desc); - assert_eq!(parsed.sequence_number, original.sequence_number); - assert_eq!(parsed.filename, original.filename); - assert_eq!(parsed.created, original.created); - assert_eq!(parsed.modified, original.modified); - assert_eq!(parsed.keywords, original.keywords); - } - - #[test] - fn object_info_to_bytes_large_size() { - let info = ObjectInfo { - size: 5_000_000_000, // 5GB, larger than u32::MAX - ..Default::default() - }; - - let bytes = info.to_bytes().unwrap(); - let parsed = ObjectInfo::from_bytes(&bytes).unwrap(); - - // Should be capped at u32::MAX when serializing - assert_eq!(parsed.size, u32::MAX as u64); - } - - #[test] - fn object_info_is_folder_by_format() { - let info = ObjectInfo { - format: ObjectFormatCode::Association, - association_type: AssociationType::None, - ..Default::default() - }; - assert!(info.is_folder()); - } - - #[test] - fn object_info_is_folder_by_association() { - let info = ObjectInfo { - format: ObjectFormatCode::Undefined, - association_type: AssociationType::GenericFolder, - ..Default::default() - }; - assert!(info.is_folder()); - } - - #[test] - fn object_info_is_file() { - let info = ObjectInfo { - format: ObjectFormatCode::Jpeg, - association_type: AssociationType::None, - ..Default::default() - }; - assert!(info.is_file()); - assert!(!info.is_folder()); - } - - #[test] - fn object_info_parse_insufficient_bytes() { - let buf = vec![0x00; 10]; // Not enough bytes - assert!(ObjectInfo::from_bytes(&buf).is_err()); - } - - #[test] - fn object_info_default() { - let info = ObjectInfo::default(); - assert_eq!(info.storage_id, StorageId::default()); - assert_eq!(info.format, ObjectFormatCode::Undefined); - assert_eq!(info.protection_status, ProtectionStatus::None); - assert_eq!(info.size, 0); - assert_eq!(info.filename, ""); - assert!(info.created.is_none()); - assert!(info.modified.is_none()); - } - - // Fuzz tests using shared macros - crate::fuzz_bytes!(fuzz_object_info, ObjectInfo, 200); - - #[test] - fn object_info_minimum_valid() { - // ObjectInfo has many fixed fields before strings - // StorageID(4) + Format(2) + Protection(2) + Size(4) + ThumbFormat(2) + ThumbSize(4) + - // ThumbW(4) + ThumbH(4) + ImgW(4) + ImgH(4) + BitDepth(4) + Parent(4) + AssocType(2) + - // AssocDesc(4) + SeqNum(4) = 52 bytes + 4 strings - assert!(ObjectInfo::from_bytes(&[]).is_err()); - assert!(ObjectInfo::from_bytes(&[0; 51]).is_err()); - assert!(ObjectInfo::from_bytes(&[0; 52]).is_err()); // Still need string data - } - - #[test] - fn object_info_size_u32_max() { - // When size is u32::MAX (0xFFFFFFFF), it indicates >4GB file - let mut buf = build_file_object_info_bytes(); - // Replace size field (bytes 8-11, after StorageID(4) + Format(2) + Protection(2)) - buf[8] = 0xFF; - buf[9] = 0xFF; - buf[10] = 0xFF; - buf[11] = 0xFF; - - let info = ObjectInfo::from_bytes(&buf).unwrap(); - assert_eq!(info.size, u32::MAX as u64); - } -} diff --git a/vendor/mtp-rs/src/ptp/types/properties.rs b/vendor/mtp-rs/src/ptp/types/properties.rs deleted file mode 100644 index c212265..0000000 --- a/vendor/mtp-rs/src/ptp/types/properties.rs +++ /dev/null @@ -1,772 +0,0 @@ -//! Property-related types for MTP/PTP device properties. -//! -//! This module contains types for working with device properties: -//! - [`PropertyValue`]: A property value with its associated type -//! - [`PropertyFormType`]: Form type for property value constraints -//! - [`PropertyRange`]: Range constraint for a property value -//! - [`DevicePropDesc`]: Device property descriptor - -use crate::ptp::codes::{DevicePropertyCode, PropertyDataType}; -use crate::ptp::pack::{ - pack_i16, pack_i32, pack_i64, pack_i8, pack_string, pack_u16, pack_u32, pack_u64, pack_u8, - unpack_i16, unpack_i32, unpack_i64, unpack_i8, unpack_string, unpack_u16, unpack_u64, - unpack_u8, -}; - -// --- PropertyValue Enum --- - -/// A property value with its associated type. -/// -/// Used for device property values in `DevicePropDesc`, as well as -/// for get/set device property operations. -#[derive(Debug, Clone, PartialEq)] -pub enum PropertyValue { - /// Signed 8-bit integer. - Int8(i8), - /// Unsigned 8-bit integer. - Uint8(u8), - /// Signed 16-bit integer. - Int16(i16), - /// Unsigned 16-bit integer. - Uint16(u16), - /// Signed 32-bit integer. - Int32(i32), - /// Unsigned 32-bit integer. - Uint32(u32), - /// Signed 64-bit integer. - Int64(i64), - /// Unsigned 64-bit integer. - Uint64(u64), - /// UTF-16LE encoded string. - String(String), -} - -impl PropertyValue { - /// Serialize this property value to bytes. - pub fn to_bytes(&self) -> Vec { - match self { - PropertyValue::Int8(v) => pack_i8(*v).to_vec(), - PropertyValue::Uint8(v) => pack_u8(*v).to_vec(), - PropertyValue::Int16(v) => pack_i16(*v).to_vec(), - PropertyValue::Uint16(v) => pack_u16(*v).to_vec(), - PropertyValue::Int32(v) => pack_i32(*v).to_vec(), - PropertyValue::Uint32(v) => pack_u32(*v).to_vec(), - PropertyValue::Int64(v) => pack_i64(*v).to_vec(), - PropertyValue::Uint64(v) => pack_u64(*v).to_vec(), - PropertyValue::String(v) => pack_string(v), - } - } - - /// Parse a property value from bytes given the expected data type. - /// - /// Returns the parsed value and the number of bytes consumed. - pub fn from_bytes( - buf: &[u8], - data_type: PropertyDataType, - ) -> Result<(Self, usize), crate::Error> { - match data_type { - PropertyDataType::Int8 => { - let val = unpack_i8(buf)?; - Ok((PropertyValue::Int8(val), 1)) - } - PropertyDataType::Uint8 => { - let val = unpack_u8(buf)?; - Ok((PropertyValue::Uint8(val), 1)) - } - PropertyDataType::Int16 => { - let val = unpack_i16(buf)?; - Ok((PropertyValue::Int16(val), 2)) - } - PropertyDataType::Uint16 => { - let val = unpack_u16(buf)?; - Ok((PropertyValue::Uint16(val), 2)) - } - PropertyDataType::Int32 => { - let val = unpack_i32(buf)?; - Ok((PropertyValue::Int32(val), 4)) - } - PropertyDataType::Uint32 => { - let val = crate::ptp::pack::unpack_u32(buf)?; - Ok((PropertyValue::Uint32(val), 4)) - } - PropertyDataType::Int64 => { - let val = unpack_i64(buf)?; - Ok((PropertyValue::Int64(val), 8)) - } - PropertyDataType::Uint64 => { - let val = unpack_u64(buf)?; - Ok((PropertyValue::Uint64(val), 8)) - } - PropertyDataType::String => { - let (val, consumed) = unpack_string(buf)?; - Ok((PropertyValue::String(val), consumed)) - } - PropertyDataType::Undefined - | PropertyDataType::Int128 - | PropertyDataType::Uint128 - | PropertyDataType::Unknown(_) => Err(crate::Error::invalid_data(format!( - "unsupported property data type: {:?}", - data_type - ))), - } - } - - /// Get the data type of this property value. - #[must_use] - pub fn data_type(&self) -> PropertyDataType { - match self { - PropertyValue::Int8(_) => PropertyDataType::Int8, - PropertyValue::Uint8(_) => PropertyDataType::Uint8, - PropertyValue::Int16(_) => PropertyDataType::Int16, - PropertyValue::Uint16(_) => PropertyDataType::Uint16, - PropertyValue::Int32(_) => PropertyDataType::Int32, - PropertyValue::Uint32(_) => PropertyDataType::Uint32, - PropertyValue::Int64(_) => PropertyDataType::Int64, - PropertyValue::Uint64(_) => PropertyDataType::Uint64, - PropertyValue::String(_) => PropertyDataType::String, - } - } -} - -// --- PropertyFormType Enum --- - -/// Form type for property value constraints. -/// -/// Describes how allowed values are specified for a property. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PropertyFormType { - /// No constraints (any value valid). - #[default] - None, - /// Value must be within a range (min, max, step). - Range, - /// Value must be one of an enumerated set. - Enumeration, - /// Unknown form type. - Unknown(u8), -} - -impl PropertyFormType { - /// Convert a raw u8 code to a PropertyFormType. - #[must_use] - pub fn from_code(code: u8) -> Self { - match code { - 0x00 => PropertyFormType::None, - 0x01 => PropertyFormType::Range, - 0x02 => PropertyFormType::Enumeration, - _ => PropertyFormType::Unknown(code), - } - } - - /// Convert a PropertyFormType to its raw u8 value. - #[must_use] - pub fn to_code(self) -> u8 { - match self { - PropertyFormType::None => 0x00, - PropertyFormType::Range => 0x01, - PropertyFormType::Enumeration => 0x02, - PropertyFormType::Unknown(code) => code, - } - } -} - -// --- PropertyRange Struct --- - -/// Range constraint for a property value. -/// -/// Used when `PropertyFormType::Range` is specified. -#[derive(Debug, Clone, PartialEq)] -pub struct PropertyRange { - /// Minimum allowed value. - pub min: PropertyValue, - /// Maximum allowed value. - pub max: PropertyValue, - /// Step size between allowed values. - pub step: PropertyValue, -} - -impl PropertyRange { - /// Parse a PropertyRange from bytes given the data type. - /// - /// Returns the parsed range and the number of bytes consumed. - pub fn from_bytes( - buf: &[u8], - data_type: PropertyDataType, - ) -> Result<(Self, usize), crate::Error> { - let mut offset = 0; - - let (min, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - offset += consumed; - - let (max, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - offset += consumed; - - let (step, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - offset += consumed; - - Ok((PropertyRange { min, max, step }, offset)) - } - - /// Serialize this property range to bytes. - pub fn to_bytes(&self) -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&self.min.to_bytes()); - buf.extend_from_slice(&self.max.to_bytes()); - buf.extend_from_slice(&self.step.to_bytes()); - buf - } -} - -// --- DevicePropDesc Structure --- - -/// Device property descriptor. -/// -/// Describes a device property including its type, current value, -/// default value, and allowed values/ranges. -/// -/// Returned by the GetDevicePropDesc operation. -#[derive(Debug, Clone)] -pub struct DevicePropDesc { - /// Property code identifying this property. - pub property_code: DevicePropertyCode, - /// Data type of the property value. - pub data_type: PropertyDataType, - /// Whether the property is writable (true) or read-only (false). - pub writable: bool, - /// Default/factory value. - pub default_value: PropertyValue, - /// Current value. - pub current_value: PropertyValue, - /// Form type (None, Range, or Enumeration). - pub form_type: PropertyFormType, - /// Allowed values (if form_type is Enumeration). - pub enum_values: Option>, - /// Value range (if form_type is Range). - pub range: Option, -} - -impl DevicePropDesc { - /// Parse a DevicePropDesc from bytes. - /// - /// The buffer should contain the DevicePropDesc dataset as returned - /// by GetDevicePropDesc. - pub fn from_bytes(buf: &[u8]) -> Result { - let mut offset = 0; - - // 1. PropertyCode (u16) - let property_code = DevicePropertyCode::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 2. DataType (u16) - let data_type = PropertyDataType::from(unpack_u16(&buf[offset..])?); - offset += 2; - - // 3. GetSet (u8): 0x00 = read-only, 0x01 = read-write - if buf.len() <= offset { - return Err(crate::Error::invalid_data( - "DevicePropDesc: insufficient bytes for GetSet", - )); - } - let writable = buf[offset] != 0x00; - offset += 1; - - // 4. DefaultValue (variable size based on data type) - let (default_value, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - offset += consumed; - - // 5. CurrentValue (variable size based on data type) - let (current_value, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - offset += consumed; - - // 6. FormFlag (u8) - if buf.len() <= offset { - return Err(crate::Error::invalid_data( - "DevicePropDesc: insufficient bytes for FormFlag", - )); - } - let form_type = PropertyFormType::from_code(buf[offset]); - offset += 1; - - // 7. Form data (depends on form_type) - let (enum_values, range) = match form_type { - PropertyFormType::None | PropertyFormType::Unknown(_) => (None, None), - PropertyFormType::Range => { - let (range, _consumed) = PropertyRange::from_bytes(&buf[offset..], data_type)?; - (None, Some(range)) - } - PropertyFormType::Enumeration => { - // Number of values (u16) - let count = unpack_u16(&buf[offset..])? as usize; - offset += 2; - - let mut values = Vec::with_capacity(count); - for _ in 0..count { - let (val, consumed) = PropertyValue::from_bytes(&buf[offset..], data_type)?; - values.push(val); - offset += consumed; - } - (Some(values), None) - } - }; - - Ok(DevicePropDesc { - property_code, - data_type, - writable, - default_value, - current_value, - form_type, - enum_values, - range, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ptp::pack::{pack_i16, pack_u16}; - use proptest::prelude::*; - - // --- PropertyValue tests --- - - #[test] - fn property_value_roundtrip() { - let values = [ - PropertyValue::Int8(-42), - PropertyValue::Uint8(100), - PropertyValue::Int16(-1000), - PropertyValue::Uint16(5000), - PropertyValue::Int32(-100000), - PropertyValue::Uint32(100000), - PropertyValue::Int64(-1_000_000_000), - PropertyValue::Uint64(1_000_000_000), - PropertyValue::String("Test".to_string()), - ]; - for val in &values { - let bytes = val.to_bytes(); - let (parsed, _) = PropertyValue::from_bytes(&bytes, val.data_type()).unwrap(); - assert_eq!(&parsed, val); - } - } - - #[test] - fn property_value_data_type() { - let cases: &[(PropertyValue, PropertyDataType)] = &[ - (PropertyValue::Int8(0), PropertyDataType::Int8), - (PropertyValue::Uint8(0), PropertyDataType::Uint8), - (PropertyValue::Int16(0), PropertyDataType::Int16), - (PropertyValue::Uint16(0), PropertyDataType::Uint16), - (PropertyValue::Int32(0), PropertyDataType::Int32), - (PropertyValue::Uint32(0), PropertyDataType::Uint32), - (PropertyValue::Int64(0), PropertyDataType::Int64), - (PropertyValue::Uint64(0), PropertyDataType::Uint64), - (PropertyValue::String("".into()), PropertyDataType::String), - ]; - for (val, expected) in cases { - assert_eq!(val.data_type(), *expected); - } - } - - #[test] - fn property_value_from_bytes_unsupported_type() { - for dt in [ - PropertyDataType::Undefined, - PropertyDataType::Int128, - PropertyDataType::Uint128, - PropertyDataType::Unknown(0x99), - ] { - assert!(PropertyValue::from_bytes(&[0x00], dt).is_err()); - } - } - - #[test] - fn property_value_from_bytes_insufficient_bytes() { - assert!(PropertyValue::from_bytes(&[], PropertyDataType::Int8).is_err()); - assert!(PropertyValue::from_bytes(&[0x00], PropertyDataType::Int16).is_err()); - assert!(PropertyValue::from_bytes(&[0x00, 0x00], PropertyDataType::Int32).is_err()); - assert!(PropertyValue::from_bytes(&[0x00; 7], PropertyDataType::Int64).is_err()); - } - - // --- PropertyFormType tests --- - - #[test] - fn property_form_type_from_code() { - for (code, expected) in [ - (0x00, PropertyFormType::None), - (0x01, PropertyFormType::Range), - (0x02, PropertyFormType::Enumeration), - ] { - assert_eq!(PropertyFormType::from_code(code), expected); - assert_eq!(expected.to_code(), code); - } - assert_eq!( - PropertyFormType::from_code(0x99), - PropertyFormType::Unknown(0x99) - ); - } - - #[test] - fn property_form_type_to_code() { - assert_eq!(PropertyFormType::None.to_code(), 0x00); - assert_eq!(PropertyFormType::Range.to_code(), 0x01); - assert_eq!(PropertyFormType::Enumeration.to_code(), 0x02); - assert_eq!(PropertyFormType::Unknown(0x99).to_code(), 0x99); - } - - #[test] - fn property_form_type_roundtrip() { - for f in [ - PropertyFormType::None, - PropertyFormType::Range, - PropertyFormType::Enumeration, - ] { - assert_eq!(PropertyFormType::from_code(f.to_code()), f); - } - } - - // --- PropertyRange tests --- - - #[test] - fn property_range_from_bytes_uint8() { - let buf = vec![0x00, 0x64, 0x01]; // min=0, max=100, step=1 - let (range, consumed) = PropertyRange::from_bytes(&buf, PropertyDataType::Uint8).unwrap(); - assert_eq!(range.min, PropertyValue::Uint8(0)); - assert_eq!(range.max, PropertyValue::Uint8(100)); - assert_eq!(range.step, PropertyValue::Uint8(1)); - assert_eq!(consumed, 3); - } - - #[test] - fn property_range_from_bytes_uint16() { - let buf = vec![0x64, 0x00, 0x00, 0x19, 0x64, 0x00]; // min=100, max=6400, step=100 - let (range, consumed) = PropertyRange::from_bytes(&buf, PropertyDataType::Uint16).unwrap(); - assert_eq!(range.min, PropertyValue::Uint16(100)); - assert_eq!(range.max, PropertyValue::Uint16(6400)); - assert_eq!(range.step, PropertyValue::Uint16(100)); - assert_eq!(consumed, 6); - } - - #[test] - fn property_range_to_bytes() { - let range = PropertyRange { - min: PropertyValue::Uint8(0), - max: PropertyValue::Uint8(100), - step: PropertyValue::Uint8(1), - }; - assert_eq!(range.to_bytes(), vec![0x00, 0x64, 0x01]); - } - - #[test] - fn property_range_roundtrip() { - let range = PropertyRange { - min: PropertyValue::Uint16(100), - max: PropertyValue::Uint16(6400), - step: PropertyValue::Uint16(100), - }; - let bytes = range.to_bytes(); - let (parsed, _) = PropertyRange::from_bytes(&bytes, PropertyDataType::Uint16).unwrap(); - assert_eq!(parsed.min, range.min); - assert_eq!(parsed.max, range.max); - assert_eq!(parsed.step, range.step); - } - - // --- DevicePropDesc tests --- - - fn build_battery_level_prop_desc(current: u8) -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&pack_u16(0x5001)); // BatteryLevel - buf.extend_from_slice(&pack_u16(0x0002)); // UINT8 - buf.push(0x00); // read-only - buf.push(100); // default - buf.push(current); // current - buf.push(0x01); // Range - buf.extend([0, 100, 1]); // min, max, step - buf - } - - #[test] - fn device_prop_desc_parse_battery_level() { - let desc = DevicePropDesc::from_bytes(&build_battery_level_prop_desc(75)).unwrap(); - assert_eq!(desc.property_code, DevicePropertyCode::BatteryLevel); - assert_eq!(desc.data_type, PropertyDataType::Uint8); - assert!(!desc.writable); - assert_eq!(desc.current_value, PropertyValue::Uint8(75)); - assert_eq!(desc.form_type, PropertyFormType::Range); - let range = desc.range.unwrap(); - assert_eq!(range.min, PropertyValue::Uint8(0)); - assert_eq!(range.max, PropertyValue::Uint8(100)); - } - - fn build_iso_prop_desc() -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&pack_u16(0x500F)); // ExposureIndex - buf.extend_from_slice(&pack_u16(0x0004)); // UINT16 - buf.push(0x01); // read-write - buf.extend_from_slice(&pack_u16(400)); // default - buf.extend_from_slice(&pack_u16(800)); // current - buf.push(0x02); // Enumeration - buf.extend_from_slice(&pack_u16(4)); // count - for iso in [100u16, 200, 400, 800] { - buf.extend_from_slice(&pack_u16(iso)); - } - buf - } - - #[test] - fn device_prop_desc_parse_iso_enumeration() { - let desc = DevicePropDesc::from_bytes(&build_iso_prop_desc()).unwrap(); - assert_eq!(desc.property_code, DevicePropertyCode::ExposureIndex); - assert!(desc.writable); - assert_eq!(desc.current_value, PropertyValue::Uint16(800)); - assert_eq!(desc.form_type, PropertyFormType::Enumeration); - let values = desc.enum_values.unwrap(); - assert_eq!( - values, - vec![ - PropertyValue::Uint16(100), - PropertyValue::Uint16(200), - PropertyValue::Uint16(400), - PropertyValue::Uint16(800), - ] - ); - } - - fn build_datetime_prop_desc() -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&pack_u16(0x5011)); // DateTime - buf.extend_from_slice(&pack_u16(0xFFFF)); // String - buf.push(0x01); // read-write - buf.push(0x00); // default: empty - buf.extend_from_slice(&pack_string("20240315T120000")); - buf.push(0x00); // None - buf - } - - #[test] - fn device_prop_desc_parse_datetime_no_form() { - let desc = DevicePropDesc::from_bytes(&build_datetime_prop_desc()).unwrap(); - assert_eq!(desc.property_code, DevicePropertyCode::DateTime); - assert_eq!(desc.data_type, PropertyDataType::String); - assert_eq!( - desc.current_value, - PropertyValue::String("20240315T120000".into()) - ); - assert_eq!(desc.form_type, PropertyFormType::None); - } - - fn build_exposure_bias_prop_desc() -> Vec { - let mut buf = Vec::new(); - buf.extend_from_slice(&pack_u16(0x5010)); // ExposureBiasCompensation - buf.extend_from_slice(&pack_u16(0x0003)); // INT16 - buf.push(0x01); - buf.extend_from_slice(&pack_i16(0)); // default - buf.extend_from_slice(&pack_i16(-1000)); // current - buf.push(0x01); // Range - buf.extend_from_slice(&pack_i16(-3000)); - buf.extend_from_slice(&pack_i16(3000)); - buf.extend_from_slice(&pack_i16(333)); - buf - } - - #[test] - fn device_prop_desc_parse_exposure_bias_signed() { - let desc = DevicePropDesc::from_bytes(&build_exposure_bias_prop_desc()).unwrap(); - assert_eq!( - desc.property_code, - DevicePropertyCode::ExposureBiasCompensation - ); - assert_eq!(desc.current_value, PropertyValue::Int16(-1000)); - let range = desc.range.unwrap(); - assert_eq!(range.min, PropertyValue::Int16(-3000)); - assert_eq!(range.max, PropertyValue::Int16(3000)); - } - - #[test] - fn device_prop_desc_parse_insufficient_bytes() { - assert!(DevicePropDesc::from_bytes(&[0x01]).is_err()); - assert!(DevicePropDesc::from_bytes(&[0x01, 0x50]).is_err()); - } - - #[test] - fn device_prop_desc_minimum_valid() { - assert!(DevicePropDesc::from_bytes(&[]).is_err()); - assert!(DevicePropDesc::from_bytes(&[0; 4]).is_err()); - } - - // --- Property-based tests --- - - proptest! { - #[test] - fn prop_property_form_type_known_roundtrip(code in 0u8..=2u8) { - let form = PropertyFormType::from_code(code); - prop_assert_eq!(form.to_code(), code); - } - - #[test] - fn prop_property_form_type_unknown_preserves_code(code in 3u8..=u8::MAX) { - let form = PropertyFormType::from_code(code); - prop_assert_eq!(form, PropertyFormType::Unknown(code)); - prop_assert_eq!(form.to_code(), code); - } - - #[test] - fn prop_property_value_int8_roundtrip(val: i8) { - let pv = PropertyValue::Int8(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int8).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_int16_roundtrip(val: i16) { - let pv = PropertyValue::Int16(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int16).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_int32_roundtrip(val: i32) { - let pv = PropertyValue::Int32(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int32).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_int64_roundtrip(val: i64) { - let pv = PropertyValue::Int64(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Int64).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_uint16_roundtrip(val: u16) { - let pv = PropertyValue::Uint16(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint16).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_uint32_roundtrip(val: u32) { - let pv = PropertyValue::Uint32(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint32).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_value_uint64_roundtrip(val: u64) { - let pv = PropertyValue::Uint64(val); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::Uint64).unwrap(); - prop_assert_eq!(parsed, pv); - } - - #[test] - fn prop_property_range_uint8_roundtrip(min: u8, max: u8, step: u8) { - let range = PropertyRange { - min: PropertyValue::Uint8(min), - max: PropertyValue::Uint8(max), - step: PropertyValue::Uint8(step), - }; - let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint8).unwrap(); - prop_assert_eq!(parsed.min, range.min); - prop_assert_eq!(parsed.max, range.max); - prop_assert_eq!(parsed.step, range.step); - } - - #[test] - fn prop_property_range_uint16_roundtrip(min: u16, max: u16, step: u16) { - let range = PropertyRange { - min: PropertyValue::Uint16(min), - max: PropertyValue::Uint16(max), - step: PropertyValue::Uint16(step), - }; - let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint16).unwrap(); - prop_assert_eq!(parsed.min, range.min); - prop_assert_eq!(parsed.max, range.max); - } - - #[test] - fn prop_property_range_int16_roundtrip(min: i16, max: i16, step: i16) { - let range = PropertyRange { - min: PropertyValue::Int16(min), - max: PropertyValue::Int16(max), - step: PropertyValue::Int16(step), - }; - let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Int16).unwrap(); - prop_assert_eq!(parsed.min, range.min); - prop_assert_eq!(parsed.max, range.max); - } - - #[test] - fn prop_property_range_uint32_roundtrip(min: u32, max: u32, step: u32) { - let range = PropertyRange { - min: PropertyValue::Uint32(min), - max: PropertyValue::Uint32(max), - step: PropertyValue::Uint32(step), - }; - let (parsed, _) = PropertyRange::from_bytes(&range.to_bytes(), PropertyDataType::Uint32).unwrap(); - prop_assert_eq!(parsed.min, range.min); - prop_assert_eq!(parsed.max, range.max); - } - } - - // --- Fuzz tests --- - - fn valid_property_string() -> impl Strategy { - prop::collection::vec( - prop::char::range('\u{0000}', '\u{D7FF}') - .prop_union(prop::char::range('\u{E000}', '\u{FFFF}')), - 0..50, - ) - .prop_map(|chars| chars.into_iter().collect::()) - } - - proptest! { - #[test] - fn prop_property_value_string_roundtrip(s in valid_property_string()) { - let s = if s.chars().count() > 254 { s.chars().take(254).collect() } else { s }; - let pv = PropertyValue::String(s.clone()); - let (parsed, _) = PropertyValue::from_bytes(&pv.to_bytes(), PropertyDataType::String).unwrap(); - prop_assert_eq!(parsed, PropertyValue::String(s)); - } - - #[test] - fn fuzz_property_value_truncated(data_type_code in 1u16..=8u16, bytes in prop::collection::vec(any::(), 0..20)) { - let _ = PropertyValue::from_bytes(&bytes, PropertyDataType::from(data_type_code)); - } - - #[test] - fn fuzz_property_value_empty(data_type_code in 1u16..=8u16) { - prop_assert!(PropertyValue::from_bytes(&[], PropertyDataType::from(data_type_code)).is_err()); - } - - #[test] - fn fuzz_property_value_string_garbage(bytes in prop::collection::vec(any::(), 0..50)) { - let _ = PropertyValue::from_bytes(&bytes, PropertyDataType::String); - } - - #[test] - fn fuzz_property_value_unsupported(bytes in prop::collection::vec(any::(), 0..20)) { - prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Undefined).is_err()); - prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Int128).is_err()); - prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Uint128).is_err()); - } - - #[test] - fn fuzz_property_value_unknown_type(unknown_code in 11u16..=0xFFFEu16, bytes in prop::collection::vec(any::(), 0..20)) { - prop_assert!(PropertyValue::from_bytes(&bytes, PropertyDataType::Unknown(unknown_code)).is_err()); - } - - #[test] - fn fuzz_property_range_truncated(data_type_code in 1u16..=8u16, bytes in prop::collection::vec(any::(), 0..20)) { - let _ = PropertyRange::from_bytes(&bytes, PropertyDataType::from(data_type_code)); - } - - #[test] - fn fuzz_property_range_wrong_type(bytes in prop::collection::vec(any::(), 0..20)) { - prop_assert!(PropertyRange::from_bytes(&bytes, PropertyDataType::Undefined).is_err()); - prop_assert!(PropertyRange::from_bytes(&bytes, PropertyDataType::Unknown(0x1234)).is_err()); - } - } - - crate::fuzz_bytes!(fuzz_device_prop_desc, DevicePropDesc, 200); -} diff --git a/vendor/mtp-rs/src/ptp/types/storage.rs b/vendor/mtp-rs/src/ptp/types/storage.rs deleted file mode 100644 index ad9d1a5..0000000 --- a/vendor/mtp-rs/src/ptp/types/storage.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Storage-related type enums for MTP/PTP. -//! -//! This module contains enums for describing storage characteristics: -//! - [`StorageType`]: Type of storage medium (ROM, RAM, etc.) -//! - [`FilesystemType`]: Type of filesystem on the storage -//! - [`AccessCapability`]: Read/write access capabilities -//! - [`ProtectionStatus`]: Object protection status -//! - [`AssociationType`]: Association type for objects (folder/container type) - -use num_enum::{FromPrimitive, IntoPrimitive}; - -/// Type of storage medium. -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum StorageType { - /// Undefined storage type. - Undefined = 0, - /// Fixed ROM (e.g., internal flash). - FixedRom = 1, - /// Removable ROM. - RemovableRom = 2, - /// Fixed RAM. - FixedRam = 3, - /// Removable RAM (e.g., SD card). - RemovableRam = 4, - /// Unknown storage type code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all -impl Default for StorageType { - fn default() -> Self { - Self::Undefined - } -} - -/// Type of filesystem on the storage. -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum FilesystemType { - /// Undefined filesystem type. - Undefined = 0, - /// Generic flat filesystem (no folders). - GenericFlat = 1, - /// Generic hierarchical filesystem (with folders). - GenericHierarchical = 2, - /// DCF (Design rule for Camera File system). - Dcf = 3, - /// Unknown filesystem type code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all -impl Default for FilesystemType { - fn default() -> Self { - Self::Undefined - } -} - -/// Access capability of the storage. -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum AccessCapability { - /// Read-write access. - ReadWrite = 0, - /// Read-only, deletion not allowed. - ReadOnlyWithoutDeletion = 1, - /// Read-only, deletion allowed. - ReadOnlyWithDeletion = 2, - /// Unknown access capability code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all -impl Default for AccessCapability { - fn default() -> Self { - Self::ReadWrite - } -} - -/// Protection status of an object. -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum ProtectionStatus { - /// No protection. - None = 0, - /// Read-only protection. - ReadOnly = 1, - /// Unknown protection status code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all -impl Default for ProtectionStatus { - fn default() -> Self { - Self::None - } -} - -/// Association type for objects (folder/container type). -#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] -#[repr(u16)] -pub enum AssociationType { - /// No association (regular file). - None = 0, - /// Generic folder. - GenericFolder = 1, - /// Unknown association type code. - #[num_enum(catch_all)] - Unknown(u16), -} - -#[allow(clippy::derivable_impls)] // Can't derive due to num_enum catch_all -impl Default for AssociationType { - fn default() -> Self { - Self::None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - #[test] - fn storage_type_conversions() { - for (code, expected) in [ - (0, StorageType::Undefined), - (1, StorageType::FixedRom), - (2, StorageType::RemovableRom), - (3, StorageType::FixedRam), - (4, StorageType::RemovableRam), - ] { - assert_eq!(StorageType::from(code), expected); - assert_eq!(u16::from(expected), code); - } - assert_eq!(StorageType::from(99u16), StorageType::Unknown(99)); - assert_eq!(StorageType::default(), StorageType::Undefined); - } - - #[test] - fn filesystem_type_conversions() { - for (code, expected) in [ - (0, FilesystemType::Undefined), - (1, FilesystemType::GenericFlat), - (2, FilesystemType::GenericHierarchical), - (3, FilesystemType::Dcf), - ] { - assert_eq!(FilesystemType::from(code), expected); - assert_eq!(u16::from(expected), code); - } - assert_eq!(FilesystemType::from(99u16), FilesystemType::Unknown(99)); - assert_eq!(FilesystemType::default(), FilesystemType::Undefined); - } - - #[test] - fn access_capability_conversions() { - for (code, expected) in [ - (0, AccessCapability::ReadWrite), - (1, AccessCapability::ReadOnlyWithoutDeletion), - (2, AccessCapability::ReadOnlyWithDeletion), - ] { - assert_eq!(AccessCapability::from(code), expected); - assert_eq!(u16::from(expected), code); - } - assert_eq!(AccessCapability::from(99u16), AccessCapability::Unknown(99)); - assert_eq!(AccessCapability::default(), AccessCapability::ReadWrite); - } - - #[test] - fn protection_status_conversions() { - for (code, expected) in [(0, ProtectionStatus::None), (1, ProtectionStatus::ReadOnly)] { - assert_eq!(ProtectionStatus::from(code), expected); - assert_eq!(u16::from(expected), code); - } - assert_eq!(ProtectionStatus::from(99u16), ProtectionStatus::Unknown(99)); - assert_eq!(ProtectionStatus::default(), ProtectionStatus::None); - } - - #[test] - fn association_type_conversions() { - for (code, expected) in [ - (0, AssociationType::None), - (1, AssociationType::GenericFolder), - ] { - assert_eq!(AssociationType::from(code), expected); - assert_eq!(u16::from(expected), code); - } - assert_eq!(AssociationType::from(99u16), AssociationType::Unknown(99)); - assert_eq!(AssociationType::default(), AssociationType::None); - } - - proptest! { - #[test] - fn prop_storage_type_roundtrip(code: u16) { - let st = StorageType::from(code); - prop_assert_eq!(u16::from(st), code); - if code > 4 { - prop_assert_eq!(st, StorageType::Unknown(code)); - } - } - - #[test] - fn prop_filesystem_type_roundtrip(code: u16) { - let ft = FilesystemType::from(code); - prop_assert_eq!(u16::from(ft), code); - if code > 3 { - prop_assert_eq!(ft, FilesystemType::Unknown(code)); - } - } - - #[test] - fn prop_access_capability_roundtrip(code: u16) { - let ac = AccessCapability::from(code); - prop_assert_eq!(u16::from(ac), code); - if code > 2 { - prop_assert_eq!(ac, AccessCapability::Unknown(code)); - } - } - - #[test] - fn prop_protection_status_roundtrip(code: u16) { - let ps = ProtectionStatus::from(code); - prop_assert_eq!(u16::from(ps), code); - if code > 1 { - prop_assert_eq!(ps, ProtectionStatus::Unknown(code)); - } - } - - #[test] - fn prop_association_type_roundtrip(code: u16) { - let at = AssociationType::from(code); - prop_assert_eq!(u16::from(at), code); - if code > 1 { - prop_assert_eq!(at, AssociationType::Unknown(code)); - } - } - } -} diff --git a/vendor/mtp-rs/src/transport/mock.rs b/vendor/mtp-rs/src/transport/mock.rs deleted file mode 100644 index 6bff636..0000000 --- a/vendor/mtp-rs/src/transport/mock.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Mock transport for testing. - -use super::Transport; -use async_trait::async_trait; -use parking_lot::Mutex; -use std::collections::VecDeque; -use std::time::Duration; - -/// Mock transport for testing MTP protocol logic without USB. -/// -/// Example: -/// ```rust,ignore -/// let mut mock = MockTransport::new(); -/// mock.expect_send(vec![0x10, 0x00, ...]); // Expected command -/// mock.queue_response(vec![0x0C, 0x00, ...]); // OK response -/// -/// // ... run test ... -/// -/// mock.verify().expect("Verification failed"); -/// ``` -pub struct MockTransport { - expected_sends: Mutex>>, - queued_responses: Mutex>>, - queued_interrupts: Mutex>>, - actual_sends: Mutex>>, - cancel_calls: Mutex>, - cancel_results: Mutex>>, -} - -impl MockTransport { - /// Create a new mock transport with no expectations or queued responses. - #[must_use] - pub fn new() -> Self { - Self { - expected_sends: Mutex::new(VecDeque::new()), - queued_responses: Mutex::new(VecDeque::new()), - queued_interrupts: Mutex::new(VecDeque::new()), - actual_sends: Mutex::new(Vec::new()), - cancel_calls: Mutex::new(Vec::new()), - cancel_results: Mutex::new(VecDeque::new()), - } - } - - /// Expect a specific byte sequence to be sent. - /// If sends don't match expectations, verify() will fail. - pub fn expect_send(&self, data: Vec) { - self.expected_sends.lock().push_back(data); - } - - /// Queue a response to be returned by receive_bulk(). - pub fn queue_response(&self, data: Vec) { - self.queued_responses.lock().push_back(data); - } - - /// Queue an interrupt response to be returned by receive_interrupt(). - pub fn queue_interrupt(&self, data: Vec) { - self.queued_interrupts.lock().push_back(data); - } - - /// Verify all expected sends occurred and all responses were consumed. - pub fn verify(&self) -> Result<(), String> { - let expected = self.expected_sends.lock(); - let responses = self.queued_responses.lock(); - let interrupts = self.queued_interrupts.lock(); - let cancel_results = self.cancel_results.lock(); - - let mut errors = Vec::new(); - - if !expected.is_empty() { - errors.push(format!( - "{} expected send(s) were not received", - expected.len() - )); - } - - if !responses.is_empty() { - errors.push(format!( - "{} queued response(s) were not consumed", - responses.len() - )); - } - - if !interrupts.is_empty() { - errors.push(format!( - "{} queued interrupt(s) were not consumed", - interrupts.len() - )); - } - - if !cancel_results.is_empty() { - errors.push(format!( - "{} queued cancel result(s) were not consumed", - cancel_results.len() - )); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } - } - - /// Get all data that was actually sent (for inspection in tests). - pub fn get_sends(&self) -> Vec> { - self.actual_sends.lock().clone() - } - - /// Queue a result for the next `cancel_transfer` call. - pub fn queue_cancel_result(&self, result: Result<(), crate::Error>) { - self.cancel_results.lock().push_back(result); - } - - /// Get all transaction IDs passed to `cancel_transfer`. - pub fn get_cancel_calls(&self) -> Vec { - self.cancel_calls.lock().clone() - } - - /// Clear all expectations and queued responses. - pub fn reset(&self) { - self.expected_sends.lock().clear(); - self.queued_responses.lock().clear(); - self.queued_interrupts.lock().clear(); - self.actual_sends.lock().clear(); - self.cancel_calls.lock().clear(); - self.cancel_results.lock().clear(); - } -} - -impl Default for MockTransport { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Transport for MockTransport { - async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { - // Store sent data for verification - self.actual_sends.lock().push(data.to_vec()); - - // If expectations exist, verify they match - let expected = self.expected_sends.lock().pop_front(); - if let Some(expected_data) = expected { - if data != expected_data.as_slice() { - return Err(crate::Error::invalid_data(format!( - "send mismatch: expected {:?}, got {:?}", - expected_data, data - ))); - } - } - - Ok(()) - } - - async fn receive_bulk(&self, _max_size: usize) -> Result, crate::Error> { - // Return next queued response or error if none - self.queued_responses - .lock() - .pop_front() - .ok_or(crate::Error::NoDevice) - } - - async fn receive_interrupt(&self) -> Result, crate::Error> { - // Return next queued interrupt or error if none - self.queued_interrupts - .lock() - .pop_front() - .ok_or(crate::Error::NoDevice) - } - - async fn cancel_transfer( - &self, - transaction_id: u32, - _idle_timeout: Duration, - ) -> Result<(), crate::Error> { - self.cancel_calls.lock().push(transaction_id); - self.cancel_results.lock().pop_front().unwrap_or(Ok(())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_send_receive() { - let mock = MockTransport::new(); - mock.queue_response(vec![1, 2, 3]); - - mock.send_bulk(&[4, 5, 6]).await.unwrap(); - let response = mock.receive_bulk(100).await.unwrap(); - - assert_eq!(response, vec![1, 2, 3]); - assert_eq!(mock.get_sends(), vec![vec![4, 5, 6]]); - } - - #[tokio::test] - async fn test_expected_send_matches() { - let mock = MockTransport::new(); - mock.expect_send(vec![1, 2, 3]); - - mock.send_bulk(&[1, 2, 3]).await.unwrap(); - mock.verify().unwrap(); - } - - #[tokio::test] - async fn test_expected_send_mismatch() { - let mock = MockTransport::new(); - mock.expect_send(vec![1, 2, 3]); - - let result = mock.send_bulk(&[1, 2, 4]).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_verify_fails_with_unconsumed_expectations() { - let mock = MockTransport::new(); - mock.expect_send(vec![1, 2, 3]); - - // Don't send anything - let result = mock.verify(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_verify_fails_with_unconsumed_responses() { - let mock = MockTransport::new(); - mock.queue_response(vec![1, 2, 3]); - - // Don't receive anything - let result = mock.verify(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_receive_bulk_empty_queue_returns_error() { - let mock = MockTransport::new(); - - let result = mock.receive_bulk(100).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_receive_interrupt() { - let mock = MockTransport::new(); - mock.queue_interrupt(vec![10, 20, 30]); - - let result = mock.receive_interrupt().await.unwrap(); - assert_eq!(result, vec![10, 20, 30]); - } - - #[tokio::test] - async fn test_receive_interrupt_empty_queue_returns_error() { - let mock = MockTransport::new(); - - let result = mock.receive_interrupt().await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_reset_clears_all_state() { - let mock = MockTransport::new(); - mock.expect_send(vec![1, 2, 3]); - mock.queue_response(vec![4, 5, 6]); - mock.queue_interrupt(vec![7, 8, 9]); - mock.send_bulk(&[10, 11, 12]).await.ok(); - - mock.reset(); - - assert!(mock.get_sends().is_empty()); - mock.verify().unwrap(); // Should pass since everything was cleared - } - - #[tokio::test] - async fn test_multiple_sends_and_responses() { - let mock = MockTransport::new(); - mock.expect_send(vec![1, 2]); - mock.expect_send(vec![3, 4]); - mock.queue_response(vec![5, 6]); - mock.queue_response(vec![7, 8]); - - mock.send_bulk(&[1, 2]).await.unwrap(); - mock.send_bulk(&[3, 4]).await.unwrap(); - - let r1 = mock.receive_bulk(100).await.unwrap(); - let r2 = mock.receive_bulk(100).await.unwrap(); - - assert_eq!(r1, vec![5, 6]); - assert_eq!(r2, vec![7, 8]); - mock.verify().unwrap(); - } - - #[tokio::test] - async fn test_default_impl() { - let mock = MockTransport::default(); - mock.queue_response(vec![1, 2, 3]); - let response = mock.receive_bulk(100).await.unwrap(); - assert_eq!(response, vec![1, 2, 3]); - } -} diff --git a/vendor/mtp-rs/src/transport/mod.rs b/vendor/mtp-rs/src/transport/mod.rs deleted file mode 100644 index bdcc647..0000000 --- a/vendor/mtp-rs/src/transport/mod.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! USB transport abstraction layer. - -#[cfg(test)] -pub mod mock; -pub mod nusb; - -#[cfg(feature = "virtual-device")] -pub mod virtual_device; - -pub use self::nusb::{NusbTransport, UsbDeviceInfo}; - -use async_trait::async_trait; -use bytes::Bytes; -use futures::Stream; -use std::pin::Pin; -use std::time::Duration; - -/// A boxed stream of byte chunks used by [`Transport::send_bulk_streaming`]. -pub type BulkStream<'a> = Pin> + Send + 'a>>; - -/// Transport trait for MTP/PTP communication. -/// -/// Abstracts USB communication to enable testing with mock transport. -#[async_trait] -pub trait Transport: Send + Sync { - /// Send data on the bulk OUT endpoint. - async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error>; - - /// Send data as a continuous bulk transfer from a stream of chunks. - /// - /// All chunks are sent as one logical USB transfer, properly terminated - /// with a short packet or ZLP. This avoids buffering the entire payload - /// in memory. - /// - /// The default implementation collects all chunks and calls `send_bulk`. - /// USB transports should override this to use native streaming writes. - async fn send_bulk_streaming(&self, chunks: BulkStream<'_>) -> Result<(), crate::Error> { - use futures::StreamExt; - let mut buffer = Vec::new(); - let mut stream = chunks; - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(crate::Error::Io)?; - buffer.extend_from_slice(&chunk); - } - self.send_bulk(&buffer).await - } - - /// Receive data from the bulk IN endpoint. - /// - /// `max_size` is the maximum bytes to receive in one call. - async fn receive_bulk(&self, max_size: usize) -> Result, crate::Error>; - - /// Receive event data from the interrupt IN endpoint. - /// - /// This may block until an event is available. - async fn receive_interrupt(&self) -> Result, crate::Error>; - - /// Cancel an in-progress transfer using the USB Still Image Class mechanism. - /// - /// Sends a CLASS_CANCEL control request (`bRequest=0x64`), then drains - /// remaining data from the bulk IN and interrupt pipes. After this call, - /// the session is clean and ready for the next operation. - /// - /// `idle_timeout` controls how long to wait during drain steps before - /// assuming each pipe is clear. 300ms (matching libmtp / Windows) is - /// the recommended default; see [`mtp::DEFAULT_CANCEL_TIMEOUT`](crate::mtp::DEFAULT_CANCEL_TIMEOUT). - async fn cancel_transfer( - &self, - transaction_id: u32, - idle_timeout: Duration, - ) -> Result<(), crate::Error>; -} diff --git a/vendor/mtp-rs/src/transport/nusb.rs b/vendor/mtp-rs/src/transport/nusb.rs deleted file mode 100644 index 3fcd0a0..0000000 --- a/vendor/mtp-rs/src/transport/nusb.rs +++ /dev/null @@ -1,777 +0,0 @@ -//! USB transport implementation using nusb. - -use super::{BulkStream, Transport}; -use async_trait::async_trait; -use futures::lock::Mutex; -use futures_timer::Delay; -use nusb::descriptors::{InterfaceDescriptor, TransferType}; -use nusb::transfer::{ - Buffer, Bulk, ControlOut, ControlType, Direction, In, Interrupt, Out, Recipient, TransferError, -}; -use nusb::MaybeFuture; -use std::time::Duration; - -/// MTP interface class code (Still Image). -const MTP_CLASS_IMAGE: u8 = 0x06; -/// MTP interface class code (Vendor-specific). -const MTP_CLASS_VENDOR: u8 = 0xFF; -/// MTP subclass code. -const MTP_SUBCLASS: u8 = 0x01; -/// MTP protocol code (PTP). -const MTP_PROTOCOL: u8 = 0x01; - -// USB Still Image Class (SIC) cancel constants. -/// SIC Cancel Request bRequest code. -const SIC_CANCEL_REQUEST: u8 = 0x64; -/// CancelTransaction event code for the cancel payload. -const SIC_CANCEL_EVENT_CODE: u16 = 0x4001; - -/// USB device information with topology-based location ID. -#[derive(Debug, Clone)] -pub struct UsbDeviceInfo { - /// USB vendor ID - pub vendor_id: u16, - /// USB product ID - pub product_id: u16, - /// Manufacturer name (e.g., "Google", "Samsung") - pub manufacturer: Option, - /// Product name (e.g., "Pixel 9 Pro XL") - pub product: Option, - /// Device serial number (if available) - pub serial_number: Option, - /// USB location identifier derived from bus and port topology (stable per port) - pub location_id: u64, - /// Reference to the underlying nusb device info for opening - nusb_info: nusb::DeviceInfo, -} - -impl UsbDeviceInfo { - /// Open the USB device. - pub fn open(&self) -> Result { - self.nusb_info.open().wait() - } -} - -/// USB transport implementation using nusb. -pub struct NusbTransport { - interface: nusb::Interface, - interface_number: u8, - bulk_in: Mutex>, - bulk_out: Mutex>, - interrupt_in: Mutex>, - /// Timeout for bulk transfers (sending commands, receiving data). - timeout: Duration, -} - -impl NusbTransport { - /// Default timeout for bulk transfers (30 seconds for large file transfers). - pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); - - /// Default buffer size for interrupt transfers. - const INTERRUPT_BUFFER_SIZE: usize = 64; - - /// List all available MTP devices with location IDs. - pub fn list_mtp_devices() -> Result, crate::Error> { - Self::list_mtp_devices_with_known(&[]) - } - - /// List all available MTP devices, including additional devices identified - /// by the given VID/PID pairs. - /// - /// Devices matching the provided VID/PID pairs are included in the results - /// even if their USB descriptors don't match standard MTP class codes. - pub fn list_mtp_devices_with_known( - known: &[(u16, u16)], - ) -> Result, crate::Error> { - let devices = nusb::list_devices() - .wait() - .map_err(crate::Error::Usb)? - .filter(|dev| Self::is_mtp_device(dev, known)) - .map(|dev| { - let location_id = location_id_from_topology(&dev); - UsbDeviceInfo { - vendor_id: dev.vendor_id(), - product_id: dev.product_id(), - manufacturer: dev.manufacturer_string().map(String::from), - product: dev.product_string().map(String::from), - serial_number: dev.serial_number().map(String::from), - location_id, - nusb_info: dev, - } - }) - .collect(); - Ok(devices) - } - - /// Check if a device info represents an MTP device. - /// - /// A device is considered MTP if it matches standard MTP class codes, has - /// an interface with the MTP endpoint layout, or matches one of the - /// caller-provided VID/PID pairs (used for devices with non-standard USB - /// descriptors that still speak MTP). - fn is_mtp_device(dev: &nusb::DeviceInfo, known: &[(u16, u16)]) -> bool { - // Fast path: caller-supplied known devices that may use non-standard descriptors. - if known - .iter() - .any(|&(v, p)| v == dev.vendor_id() && p == dev.product_id()) - { - return true; - } - - // Check device class/subclass/protocol at device level. - if Self::is_mtp_class(dev.class(), dev.subclass(), dev.protocol()) { - return true; - } - - // Many devices are composite (class 0) or vendor-specific (class 0xFF) - // with MTP on one interface. Only inspect these further. - if dev.class() != 0 && dev.class() != MTP_CLASS_VENDOR { - return false; - } - - // Check interface-level class info available from DeviceInfo without opening. - for intf in dev.interfaces() { - if Self::is_mtp_class(intf.class(), intf.subclass(), intf.protocol()) { - return true; - } - } - - // Fall back to opening the device and inspecting full configuration descriptors. - // This also catches vendor-specific interfaces (class 0xFF) that use non-standard - // subclass/protocol but have the MTP endpoint layout (e.g. Amazon Kindle). - if let Ok(device) = dev.open().wait() { - if let Ok(config) = device.active_configuration() { - for interface in config.interfaces() { - if let Some(alt) = interface.alt_settings().next() { - if Self::is_mtp_interface(&alt) { - return true; - } - } - } - } - } - - false - } - - /// Check if class/subclass/protocol match standard MTP identifiers. - fn is_mtp_class(class: u8, subclass: u8, protocol: u8) -> bool { - (class == MTP_CLASS_IMAGE || class == MTP_CLASS_VENDOR) - && subclass == MTP_SUBCLASS - && protocol == MTP_PROTOCOL - } - - /// Check if an interface descriptor looks like an MTP interface. - /// - /// Matches standard MTP class/subclass/protocol, and also vendor-specific - /// interfaces (class 0xFF) with non-standard subclass/protocol that have - /// the MTP endpoint layout (bulk IN + bulk OUT + interrupt IN). Some devices - /// like Amazon Kindle use vendor-specific descriptors while still speaking MTP. - fn is_mtp_interface(alt: &InterfaceDescriptor) -> bool { - if Self::is_mtp_class(alt.class(), alt.subclass(), alt.protocol()) { - return true; - } - // For vendor-specific class, subclass and protocol are vendor-defined, - // so we can't rely on them. Use endpoint layout as a heuristic instead. - alt.class() == MTP_CLASS_VENDOR && Self::has_mtp_endpoint_layout(alt) - } - - /// Check if an interface has the MTP endpoint layout: - /// one bulk IN, one bulk OUT, and one interrupt IN endpoint. - fn has_mtp_endpoint_layout(alt: &InterfaceDescriptor) -> bool { - let mut bulk_in = false; - let mut bulk_out = false; - let mut interrupt_in = false; - for ep in alt.endpoints() { - match (ep.direction(), ep.transfer_type()) { - (Direction::In, TransferType::Bulk) => bulk_in = true, - (Direction::Out, TransferType::Bulk) => bulk_out = true, - (Direction::In, TransferType::Interrupt) => interrupt_in = true, - _ => {} - } - } - bulk_in && bulk_out && interrupt_in - } - - /// Whether a `claim_interface` failure looks like the OS hasn't published - /// the interface yet (rather than a permanent error). - /// - /// On macOS, vendor-class or class-0 devices that IOKit doesn't - /// auto-configure end up with no `IOUSBHostInterface` services published, - /// even when the device's configuration descriptor reports otherwise. - /// The resulting `claim_interface` error is `NotFound` — there's nothing - /// for nusb to claim — and the fix is to issue `SetConfiguration(1)`, - /// which makes IOKit publish the interface objects. - #[cfg(target_os = "macos")] - fn is_interface_unpublished(e: &nusb::Error) -> bool { - matches!(e.kind(), nusb::ErrorKind::NotFound) - } - - /// Open a specific device and claim the MTP interface. - pub async fn open(device: nusb::Device) -> Result { - Self::open_with_timeout(device, Self::DEFAULT_TIMEOUT).await - } - - /// Open with custom bulk transfer timeout. - /// - /// The interface scan first looks for a strict MTP-class interface; if none - /// is found, it falls back to any interface with the MTP endpoint layout - /// (bulk IN + bulk OUT + interrupt IN). This relaxed fallback supports - /// legacy devices that report a non-standard interface class — the caller - /// has already hand-picked the device, so the scan can be permissive at - /// this point. - pub async fn open_with_timeout( - device: nusb::Device, - timeout: Duration, - ) -> Result { - // Find the MTP interface - let config = device.active_configuration().map_err(|e| { - crate::Error::invalid_data(format!("Failed to get configuration: {}", e)) - })?; - - let mut mtp_interface_number = None; - let mut bulk_in_addr = None; - let mut bulk_out_addr = None; - let mut interrupt_in_addr = None; - - // Two-pass scan: prefer a strictly-matching MTP interface, but fall - // back to any interface with the MTP endpoint layout. The caller has - // already hand-picked the device, so a permissive fallback is safe and - // supports legacy devices that report a non-standard interface class. - let pick = |strict: bool| { - for interface in config.interfaces() { - let Some(alt_setting) = interface.alt_settings().next() else { - continue; - }; - let matches = if strict { - Self::is_mtp_interface(&alt_setting) - } else { - Self::has_mtp_endpoint_layout(&alt_setting) - }; - if matches { - let mut bin = None; - let mut bout = None; - let mut iin = None; - for endpoint in alt_setting.endpoints() { - match (endpoint.direction(), endpoint.transfer_type()) { - (Direction::Out, TransferType::Bulk) => bout = Some(endpoint.address()), - (Direction::In, TransferType::Bulk) => bin = Some(endpoint.address()), - (Direction::In, TransferType::Interrupt) => { - iin = Some(endpoint.address()) - } - _ => {} - } - } - return Some((interface.interface_number(), bin, bout, iin)); - } - } - None - }; - - if let Some((n, bin, bout, iin)) = pick(true).or_else(|| pick(false)) { - mtp_interface_number = Some(n); - bulk_in_addr = bin; - bulk_out_addr = bout; - interrupt_in_addr = iin; - } - - let interface_number = mtp_interface_number - .ok_or_else(|| crate::Error::invalid_data("No MTP interface found on device"))?; - - let bulk_in_addr = - bulk_in_addr.ok_or_else(|| crate::Error::invalid_data("No bulk IN endpoint found"))?; - let bulk_out_addr = bulk_out_addr - .ok_or_else(|| crate::Error::invalid_data("No bulk OUT endpoint found"))?; - let interrupt_in_addr = interrupt_in_addr - .ok_or_else(|| crate::Error::invalid_data("No interrupt IN endpoint found"))?; - - // Claim the interface - // - // macOS: IOKit doesn't publish interface services for vendor-class / - // class-0 devices with no matching driver. Force-set configuration 1 - // so IOKit publishes them, then retry. - let interface = match device.claim_interface(interface_number).wait() { - Ok(iface) => iface, - #[cfg(target_os = "macos")] - Err(e) if Self::is_interface_unpublished(&e) => { - device - .set_configuration(1) - .wait() - .map_err(crate::Error::Usb)?; - device - .claim_interface(interface_number) - .wait() - .map_err(crate::Error::Usb)? - } - Err(e) => return Err(crate::Error::Usb(e)), - }; - - // Open endpoints - let bulk_in = interface - .endpoint::(bulk_in_addr) - .map_err(crate::Error::Usb)?; - let bulk_out = interface - .endpoint::(bulk_out_addr) - .map_err(crate::Error::Usb)?; - let interrupt_in = interface - .endpoint::(interrupt_in_addr) - .map_err(crate::Error::Usb)?; - - Ok(Self { - interface, - interface_number, - bulk_in: Mutex::new(bulk_in), - bulk_out: Mutex::new(bulk_out), - interrupt_in: Mutex::new(interrupt_in), - timeout, - }) - } - - /// Get the bulk transfer timeout. - #[must_use] - pub fn timeout(&self) -> Duration { - self.timeout - } - - /// Set the bulk transfer timeout. - pub fn set_timeout(&mut self, timeout: Duration) { - self.timeout = timeout; - } - - /// Convert a nusb TransferError to crate::Error. - fn convert_transfer_error(err: TransferError) -> crate::Error { - match err { - // send_bulk uses transfer_blocking, which cancels the transfer on - // timeout and returns Cancelled. Map to Timeout so that - // Error::is_retryable() treats it correctly. - TransferError::Cancelled => crate::Error::Timeout, - TransferError::Disconnected => crate::Error::Disconnected, - TransferError::Stall - | TransferError::Fault - | TransferError::InvalidArgument - | TransferError::Unknown(_) => crate::Error::Io(std::io::Error::other(err.to_string())), - } - } -} - -#[async_trait] -impl Transport for NusbTransport { - async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { - let mut ep = self.bulk_out.lock().await; - let buf: Buffer = data.to_vec().into(); - let completion = ep.transfer_blocking(buf, self.timeout); - completion.status.map_err(Self::convert_transfer_error)?; - Ok(()) - } - - async fn send_bulk_streaming(&self, chunks: BulkStream<'_>) -> Result<(), crate::Error> { - use futures::StreamExt; - - let mut ep = self.bulk_out.lock().await; - let max_packet_size = ep.max_packet_size(); - let transfer_size: usize = 256 * 1024; // 256KB per USB transfer - // Round up to max_packet_size boundary. - let transfer_size = (transfer_size.div_ceil(max_packet_size)).max(1) * max_packet_size; - - let mut current_buf = ep.allocate(transfer_size); - let mut total_sent = 0usize; - let mut stream = chunks; - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(crate::Error::Io)?; - let mut remaining = chunk.as_ref(); - - while !remaining.is_empty() { - let space = current_buf.remaining_capacity(); - let to_copy = remaining.len().min(space); - current_buf.extend_from_slice(&remaining[..to_copy]); - remaining = &remaining[to_copy..]; - - if current_buf.remaining_capacity() == 0 { - ep.submit(current_buf); - let completion = ep - .wait_next_complete(self.timeout) - .ok_or(crate::Error::Timeout)?; - completion.status.map_err(Self::convert_transfer_error)?; - total_sent += transfer_size; - current_buf = ep.allocate(transfer_size); - } - } - } - - // Send remaining data. - let final_len = current_buf.len(); - if final_len > 0 { - ep.submit(current_buf); - let completion = ep - .wait_next_complete(self.timeout) - .ok_or(crate::Error::Timeout)?; - completion.status.map_err(Self::convert_transfer_error)?; - - // If the final transfer was a multiple of max_packet_size, send ZLP - // so the device sees a short packet delimiter. - if final_len % max_packet_size == 0 { - ep.submit(Buffer::new(0)); - let completion = ep - .wait_next_complete(self.timeout) - .ok_or(crate::Error::Timeout)?; - completion.status.map_err(Self::convert_transfer_error)?; - } - } else if total_sent > 0 && total_sent % max_packet_size == 0 { - // All data fit in full transfers. Send ZLP to terminate. - ep.submit(Buffer::new(0)); - let completion = ep - .wait_next_complete(self.timeout) - .ok_or(crate::Error::Timeout)?; - completion.status.map_err(Self::convert_transfer_error)?; - } - - Ok(()) - } - - async fn receive_bulk(&self, max_size: usize) -> Result, crate::Error> { - let mut ep = self.bulk_in.lock().await; - - // If there's no pending transfer from a previous timed-out call, - // submit a new one. Otherwise, the pending transfer already has our - // data in flight and we just need to wait for it. - if ep.pending() == 0 { - let max_packet_size = ep.max_packet_size(); - let aligned_size = align_to_packet_size(max_size, max_packet_size); - ep.submit(Buffer::new(aligned_size)); - } - - // Wait for the transfer to complete OR the timeout to expire. - // next_complete() is cancel-safe: dropping its future does NOT cancel - // the underlying USB transfer. On timeout we leave the transfer pending - // so a subsequent call picks up the in-flight data. - let completion = futures::future::select( - Box::pin(ep.next_complete()), - Box::pin(Delay::new(self.timeout)), - ) - .await; - - match completion { - futures::future::Either::Left((comp, _)) => { - comp.status.map_err(Self::convert_transfer_error)?; - Ok(comp.buffer[..comp.actual_len].to_vec()) - } - futures::future::Either::Right((_, _)) => { - // Don't cancel the transfer — it stays pending in the endpoint. - // next_complete() is cancel-safe, so dropping its future is fine. - // On retry, the next call will find pending() > 0 and pick it up. - Err(crate::Error::Timeout) - } - } - } - - async fn receive_interrupt(&self) -> Result, crate::Error> { - let mut ep = self.interrupt_in.lock().await; - - // Submit a new transfer only if none is already pending. - if ep.pending() == 0 { - let max_packet_size = ep.max_packet_size(); - let aligned_size = align_to_packet_size(Self::INTERRUPT_BUFFER_SIZE, max_packet_size); - ep.submit(Buffer::new(aligned_size)); - } - - // Await indefinitely — callers handle cancellation via async - // cancellation (e.g. tokio::time::timeout or select!). - let completion = ep.next_complete().await; - completion.status.map_err(Self::convert_transfer_error)?; - Ok(completion.buffer[..completion.actual_len].to_vec()) - } - - /// USB Still Image Class cancel implementation. - /// - /// Getting mid-transfer cancellation right on MTP/PTP devices is notoriously - /// tricky. Different devices react differently to cancel signals, and getting - /// the timing wrong can leave the device unresponsive until physical replug. - /// - /// This implementation follows the approach proven by libmtp's - /// `ptp_read_cancel_func` (contributed by Florent Viard in 2017, PR #2), - /// which was developed through extensive real-device testing after discovering - /// that naive approaches caused Nexus 6P to fail all subsequent operations - /// and GoPro Hero 5 to freeze completely. - /// - /// The key insight: after sending the CLASS_CANCEL control request, the drain - /// must start *immediately*. The device stops sending new data quickly, but - /// data already in the USB pipeline must be read and discarded before the - /// pipe goes idle. Inserting any delay (like polling GET_DEVICE_STATUS, which - /// Android doesn't support anyway) causes the drain to find an empty pipe - /// while the device's MTP state machine remains stuck. - /// - /// The sequence is: - /// 1. Send CLASS_CANCEL control request (bRequest=0x64, 300ms timeout) - /// 2. Drain bulk IN pipe (read + discard until idle_timeout, maxpacket chunks) - /// 3. Drain interrupt pipe (consume CancelTransaction event if present) - /// - /// References: - /// - libmtp PR #2: - /// - USB Still Image Capture Device Definition, Section 5 - async fn cancel_transfer( - &self, - transaction_id: u32, - idle_timeout: Duration, - ) -> Result<(), crate::Error> { - // Step 1: Send CLASS_CANCEL control request (bRequest=0x64). - // - // 300ms timeout matches libmtp and Windows behavior. This is a - // class-specific control transfer on endpoint 0, independent of - // the bulk pipes. The 6-byte payload contains the CancelTransaction - // event code (0x4001) and the transaction ID to cancel. - let mut payload = [0u8; 6]; - payload[0..2].copy_from_slice(&SIC_CANCEL_EVENT_CODE.to_le_bytes()); - payload[2..6].copy_from_slice(&transaction_id.to_le_bytes()); - - self.interface - .control_out( - ControlOut { - control_type: ControlType::Class, - recipient: Recipient::Interface, - request: SIC_CANCEL_REQUEST, - value: 0, - index: self.interface_number as u16, - data: &payload, - }, - Duration::from_millis(300), - ) - .await - .map_err(Self::convert_transfer_error)?; - - // Step 2: Drain bulk IN pipe. - // - // Read and discard data in maxpacket-sized chunks until idle_timeout - // fires (no more data arriving). The device may still be sending data - // that was already in the USB pipeline when CLASS_CANCEL arrived. The - // drain also catches the Response container (Transaction_Cancelled or - // Ok) that the device sends to end the transaction. - // - // IMPORTANT: This must happen immediately after the control request. - // Any delay (like polling GET_DEVICE_STATUS) allows the device to - // give up waiting for us to read, leaving its MTP state machine stuck. - { - let mut ep = self.bulk_in.lock().await; - let max_packet_size = ep.max_packet_size(); - loop { - if ep.pending() == 0 { - let aligned_size = align_to_packet_size(max_packet_size, max_packet_size); - ep.submit(Buffer::new(aligned_size)); - } - - let drain_result = { - let complete_fut = ep.next_complete(); - let timeout_fut = Delay::new(idle_timeout); - futures::pin_mut!(complete_fut, timeout_fut); - - match futures::future::select(complete_fut, timeout_fut).await { - futures::future::Either::Left((completion, _)) => { - match completion.status { - Ok(()) => { - // Check for Response container (type code 3 at bytes [4..6]). - if completion.actual_len >= 6 { - let type_code = u16::from_le_bytes([ - completion.buffer[4], - completion.buffer[5], - ]); - if type_code == 3 { - Ok(true) // Response received, done - } else { - Ok(false) // Data, keep draining - } - } else { - Ok(false) // keep draining - } - } - Err(TransferError::Cancelled) => Ok(true), - Err(TransferError::Disconnected) => Err(crate::Error::Disconnected), - Err(_) => Ok(true), - } - } - futures::future::Either::Right((_, _)) => { - // Idle timeout — no more data arriving, pipe is clear. - Ok(true) - } - } - }; - - match drain_result { - Ok(true) => { - ep.cancel_all(); - while ep.pending() > 0 { - let _ = ep.next_complete().await; - } - break; - } - Ok(false) => continue, - Err(e) => return Err(e), - } - } - } - - // Step 3: Drain interrupt pipe. - // - // Consume the CancelTransaction event if the device sent one. This is - // critical for some devices — GoPro Hero 5 stops responding entirely - // if this event is left unread on the interrupt pipe. - { - let mut ep = self.interrupt_in.lock().await; - if ep.pending() == 0 { - let max_packet_size = ep.max_packet_size(); - let aligned_size = - align_to_packet_size(Self::INTERRUPT_BUFFER_SIZE, max_packet_size); - ep.submit(Buffer::new(aligned_size)); - } - - let timed_out = { - let complete_fut = ep.next_complete(); - let timeout_fut = Delay::new(idle_timeout); - futures::pin_mut!(complete_fut, timeout_fut); - - matches!( - futures::future::select(complete_fut, timeout_fut).await, - futures::future::Either::Right(_) - ) - }; - - if timed_out { - ep.cancel_all(); - while ep.pending() > 0 { - let _ = ep.next_complete().await; - } - } - } - Ok(()) - } -} - -/// Round `size` up to the nearest multiple of `packet_size`. -/// -/// nusb 0.2 requires that IN transfer buffer sizes are non-zero multiples of -/// the endpoint's maximum packet size. -fn align_to_packet_size(size: usize, packet_size: usize) -> usize { - if packet_size == 0 { - return size.max(1); - } - if size == 0 { - return packet_size; - } - if size % packet_size == 0 { - size - } else { - ((size / packet_size) + 1) * packet_size - } -} - -/// Derive a stable location identifier from USB topology (bus + port chain). -/// -/// Uses FNV-1a to hash `bus_id` and `port_chain` into a deterministic `u64`. -/// The result is stable across calls for the same physical USB port, regardless -/// of which device is plugged in. -fn location_id_from_topology(dev: &nusb::DeviceInfo) -> u64 { - // FNV-1a 64-bit constants - const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; - const FNV_PRIME: u64 = 0x0100_0000_01b3; - - let mut hash = FNV_OFFSET; - for byte in dev.bus_id().as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(FNV_PRIME); - } - // Separator so bus_id "1" + port [2,3] differs from bus_id "12" + port [3] - hash ^= 0xFF; - hash = hash.wrapping_mul(FNV_PRIME); - for byte in dev.port_chain() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(FNV_PRIME); - } - hash -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[ignore] // Requires real MTP device - fn test_list_devices() { - let devices = NusbTransport::list_mtp_devices().unwrap(); - println!("Found {} MTP devices", devices.len()); - for dev in &devices { - println!( - " {:04x}:{:04x} serial={:?} location={:08x}", - dev.vendor_id, dev.product_id, dev.serial_number, dev.location_id, - ); - } - } - - #[tokio::test] - #[ignore] // Requires real MTP device - async fn test_open_device() { - let devices = NusbTransport::list_mtp_devices().unwrap(); - assert!(!devices.is_empty(), "No MTP device found"); - - let device = devices[0].open().unwrap(); - let transport = NusbTransport::open(device).await.unwrap(); - - assert_eq!(transport.timeout(), NusbTransport::DEFAULT_TIMEOUT); - } - - #[tokio::test] - #[ignore] // Requires real MTP device - async fn test_timeout_configuration() { - let devices = NusbTransport::list_mtp_devices().unwrap(); - assert!(!devices.is_empty(), "No MTP device found"); - - let device = devices[0].open().unwrap(); - let custom_timeout = Duration::from_secs(60); - let mut transport = NusbTransport::open_with_timeout(device, custom_timeout) - .await - .unwrap(); - - assert_eq!(transport.timeout(), custom_timeout); - - // Test setter - let new_timeout = Duration::from_secs(10); - transport.set_timeout(new_timeout); - assert_eq!(transport.timeout(), new_timeout); - } - - #[test] - fn test_align_to_packet_size() { - // Zero size rounds up to packet_size - assert_eq!(align_to_packet_size(0, 512), 512); - // Size smaller than packet rounds up - assert_eq!(align_to_packet_size(1, 512), 512); - // Exact multiple stays the same - assert_eq!(align_to_packet_size(512, 512), 512); - assert_eq!(align_to_packet_size(1024, 512), 1024); - // Non-multiple rounds up - assert_eq!(align_to_packet_size(513, 512), 1024); - assert_eq!(align_to_packet_size(100, 64), 128); - // Zero packet_size edge case - assert_eq!(align_to_packet_size(0, 0), 1); - assert_eq!(align_to_packet_size(100, 0), 100); - } - - #[test] - fn test_mtp_class_detection() { - // Image class with MTP subclass/protocol - assert!(NusbTransport::is_mtp_class(0x06, 0x01, 0x01)); - - // Vendor class with MTP subclass/protocol - assert!(NusbTransport::is_mtp_class(0xFF, 0x01, 0x01)); - - // Wrong class - assert!(!NusbTransport::is_mtp_class(0x08, 0x01, 0x01)); - - // Wrong subclass - assert!(!NusbTransport::is_mtp_class(0x06, 0x00, 0x01)); - - // Wrong protocol - assert!(!NusbTransport::is_mtp_class(0x06, 0x01, 0x00)); - - // Vendor-specific with non-standard subclass/protocol (e.g. Kindle ff/ff/00) - assert!(!NusbTransport::is_mtp_class(0xFF, 0xFF, 0x00)); - } -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md b/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md deleted file mode 100644 index b2dd4e2..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/CLAUDE.md +++ /dev/null @@ -1,48 +0,0 @@ -# Virtual device transport - -Feature-gated (`virtual-device`) Transport implementation backed by local filesystem directories instead of USB. Exercises the full MTP/PTP binary protocol path. - -## Architecture - -``` -MtpDevice (unchanged) - → MtpDeviceInner (unchanged) - → PtpSession (unchanged) - → Arc - → VirtualTransport — implements Transport trait - → VirtualDeviceState — in-memory object tree + filesystem -``` - -## Module structure - -- `config.rs` — `VirtualDeviceConfig`, `VirtualStorageConfig` (public types) -- `state.rs` — `VirtualDeviceState`, `VirtualObject`, `PendingCommand`, handle management -- `builders.rs` — binary payload builders (DeviceInfo, StorageInfo, ObjectInfo, containers) -- `handlers.rs` — protocol operation handlers dispatched by opcode -- `registry.rs` — global virtual device registry for discovery integration (`list_devices`, `open_by_location`, `open_by_serial`) + active-state registry for `rescan_virtual_device()` and `pause_watcher()`/`WatcherGuard` -- `watcher.rs` — filesystem watcher for detecting out-of-band changes to backing directories -- `mod.rs` — `VirtualTransport` struct + `Transport` impl + tests - -## Key decisions - -- **`std::sync::Mutex`** over `parking_lot::Mutex`: `parking_lot` is only a dev-dep. Virtual transport isn't performance-critical, so std mutex is fine. -- **`PendingCommand` struct**: When the host sends a command that expects a data phase (SendObjectInfo, SendObject, SetObjectPropValue), the command is stored as a `PendingCommand` in `state.pending_command`. The next `send_bulk` (data container) takes it via `.take()` and dispatches both together. This keeps pending state separate from the response queue. -- **`VecDeque` for queues**: `response_queue` and `event_queue` use `VecDeque` for O(1) front removal (FIFO access pattern). -- **Discovery via global registry**: Virtual devices can be registered via `register_virtual_device()` to appear in `MtpDevice::list_devices()`. They get synthetic location IDs starting at `0xFFFF_0000_0000_0000` to avoid collisions with real USB devices. Uses `OnceLock` for the static registry. -- **Event poll interval**: `VirtualTransport` stores `event_poll_interval: Duration` outside the mutex. When no events are pending, `receive_interrupt` awaits this delay before returning `Timeout`, preventing CPU spin in event loops. Tests use `Duration::ZERO` for speed; production callers should use 50ms+. -- **Filesystem watcher**: Controlled by `VirtualDeviceConfig::watch_backing_dirs`. When `true`, a `notify::RecommendedWatcher` watches all backing dirs recursively. When files are written/deleted directly (bypassing MTP), the watcher detects changes and queues `ObjectAdded`/`ObjectRemoved` events. Gated behind `virtual-device` feature via the `notify` dependency. Tests that don't need the watcher should set this to `false` for faster startup and no background threads. -- **Watcher scope**: The filesystem watcher only tracks file/directory creation and removal. Content modifications to existing files are intentionally ignored — they don't change the object tree and would be noisy (editors write temp files, do atomic renames, etc.). Real MTP devices are also inconsistent about emitting `ObjectInfoChanged` for content edits. -- **Dedup for watcher events**: Uses state-based dedup rather than TTL tracking. MTP handlers modify the filesystem while holding the `state` mutex and insert/remove handles before releasing the lock. The watcher callback also acquires `state` before processing events. For creates, the watcher skips events when a handle already exists for the path. For removes, the watcher skips when no handle is found (already removed by the MTP handler). No extra tracking structure or timing assumptions needed. Events for the backing directory itself (empty relative path) are skipped — macOS FSEvents reports the watched directory as "created" on startup. -- **Canonical backing dirs**: `VirtualDeviceState::new()` canonicalizes all backing dirs at startup. This ensures consistent path comparison between handlers and the watcher callback (important on macOS where `/var` → `/private/var`). -- **Rescan via active-state registry**: `VirtualTransport::new()` registers its `Arc>` in a second global registry keyed by serial number. `rescan_virtual_device(serial)` looks up the state and calls `rescan_backing_dirs()`, which diffs the in-memory object tree against the filesystem, removing stale entries and adding new ones. The transport unregisters on drop. This avoids the fs watcher's latency (200-500ms on macOS FSEvents) and handles rapid delete+recreate sequences that the watcher can miss. -- **Watcher pause/resume**: `pause_watcher(serial)` returns a `WatcherGuard` (RAII) that sets `watcher_paused = true` on the device state. While paused, the watcher callback drops all events. The guard resumes on drop (poison-safe via `lock().ok()`). This prevents a race condition where external code deletes and recreates files in the backing directory: without pausing, the OS can deliver stale deletion events after a rescan has already re-added the objects. - -## Gotchas - -- `list_objects(None)` applies a parent filter (`ParentFilter::Exact(ROOT)`), so the virtual transport must set `parent = ObjectHandle::ROOT` on root-level objects for them to appear. -- `SendObjectInfo` for folders creates the directory immediately (no `SendObject` phase needed for folders per MTP spec). -- Storage IDs start at `0x00010001` (matching real MTP convention). -- The global registries (device registry + active-state registry) are process-wide and shared across tests. Registry tests must clean up with `unregister_virtual_device()` and use unique serial numbers to avoid interference. Rescan tests must also use unique serials. -- `event_poll_interval` lives on `VirtualTransport` (not inside `VirtualDeviceState`) because we need it after dropping the mutex lock and before an async `.await`. -- Fs watcher tests use canonicalized backing dirs and `poll_event_with_retry` to handle macOS FSEvents latency. The removal test drains create events first because macOS may coalesce or reorder events. -- `GetPartialObject64` (0x95C1) has a **different param layout** from `GetPartialObject` (0x101B): 4 params (handle, offset_lo, offset_hi, max_bytes) vs 3 (handle, offset, max_bytes). The two handlers share `read_partial()` for the actual file I/O but parse params separately. diff --git a/vendor/mtp-rs/src/transport/virtual_device/builders.rs b/vendor/mtp-rs/src/transport/virtual_device/builders.rs deleted file mode 100644 index a3e497b..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/builders.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Binary payload builders for virtual device responses. -//! -//! These functions produce the binary payloads that get wrapped in data containers -//! before being returned to the PTP session. - -use super::state::VirtualDeviceState; -use crate::ptp::{ - pack_string, pack_u16, pack_u16_array, pack_u32, pack_u64, EventCode, ObjectFormatCode, - ObjectHandle, OperationCode, StorageId, -}; - -/// Build a DeviceInfo binary payload from the virtual device config. -pub(super) fn build_device_info(state: &VirtualDeviceState) -> Vec { - let mut buf = Vec::with_capacity(512); - - // StandardVersion: 100 (v1.00) - buf.extend_from_slice(&pack_u16(100)); - // VendorExtensionID: 6 (Microsoft / MTP) - buf.extend_from_slice(&pack_u32(6)); - // VendorExtensionVersion: 100 - buf.extend_from_slice(&pack_u16(100)); - // VendorExtensionDesc: "microsoft.com: 1.0" - buf.extend_from_slice(&pack_string("microsoft.com: 1.0")); - // FunctionalMode: 0 (standard) - buf.extend_from_slice(&pack_u16(0)); - - // OperationsSupported - let mut ops: Vec = vec![ - OperationCode::GetDeviceInfo.into(), - OperationCode::OpenSession.into(), - OperationCode::CloseSession.into(), - OperationCode::GetStorageIds.into(), - OperationCode::GetStorageInfo.into(), - OperationCode::GetObjectHandles.into(), - OperationCode::GetObjectInfo.into(), - OperationCode::GetObject.into(), - OperationCode::GetPartialObject.into(), - OperationCode::GetPartialObject64.into(), - OperationCode::GetThumb.into(), - OperationCode::SendObjectInfo.into(), - OperationCode::SendObject.into(), - OperationCode::DeleteObject.into(), - OperationCode::MoveObject.into(), - OperationCode::CopyObject.into(), - OperationCode::GetObjectPropValue.into(), - ]; - if state.config.supports_rename { - ops.push(OperationCode::SetObjectPropValue.into()); - } - buf.extend_from_slice(&pack_u16_array(&ops)); - - // EventsSupported - let events: Vec = vec![ - EventCode::ObjectAdded.into(), - EventCode::ObjectRemoved.into(), - EventCode::StorageInfoChanged.into(), - ]; - buf.extend_from_slice(&pack_u16_array(&events)); - - // DevicePropertiesSupported: empty - buf.extend_from_slice(&pack_u16_array(&[])); - // CaptureFormats: empty - buf.extend_from_slice(&pack_u16_array(&[])); - // PlaybackFormats: common formats - let playback: Vec = vec![ - ObjectFormatCode::Undefined.into(), - ObjectFormatCode::Association.into(), - ObjectFormatCode::Text.into(), - ObjectFormatCode::Jpeg.into(), - ObjectFormatCode::Png.into(), - ObjectFormatCode::Mp3.into(), - ObjectFormatCode::Mp4Container.into(), - ]; - buf.extend_from_slice(&pack_u16_array(&playback)); - - // Manufacturer - buf.extend_from_slice(&pack_string(&state.config.manufacturer)); - // Model - buf.extend_from_slice(&pack_string(&state.config.model)); - // DeviceVersion - buf.extend_from_slice(&pack_string("1.0.0")); - // SerialNumber - buf.extend_from_slice(&pack_string(&state.config.serial)); - - buf -} - -/// Build a StorageInfo binary payload for a given storage. -pub(super) fn build_storage_info( - state: &VirtualDeviceState, - storage_id: StorageId, -) -> Option> { - let storage = state.find_storage(storage_id)?; - let mut buf = Vec::with_capacity(128); - - // StorageType: FixedRam (3) - buf.extend_from_slice(&pack_u16(3)); - // FilesystemType: GenericHierarchical (2) - buf.extend_from_slice(&pack_u16(2)); - // AccessCapability - let access = if storage.config.read_only { 1u16 } else { 0u16 }; - buf.extend_from_slice(&pack_u16(access)); - // MaxCapacity - buf.extend_from_slice(&pack_u64(storage.config.capacity)); - // FreeSpaceInBytes - compute from backing dir - let free = compute_free_space(&storage.config); - buf.extend_from_slice(&pack_u64(free)); - // FreeSpaceInObjects: 0xFFFFFFFF (unknown) - buf.extend_from_slice(&pack_u32(0xFFFF_FFFF)); - // StorageDescription - buf.extend_from_slice(&pack_string(&storage.config.description)); - // VolumeIdentifier - buf.extend_from_slice(&pack_string("")); - - Some(buf) -} - -/// Compute free space for a storage by subtracting used space from capacity. -fn compute_free_space(config: &super::config::VirtualStorageConfig) -> u64 { - let used = dir_size(&config.backing_dir); - config.capacity.saturating_sub(used) -} - -/// Recursively compute total size of all files in a directory. -fn dir_size(path: &std::path::Path) -> u64 { - if !path.is_dir() { - return 0; - } - let mut total = 0u64; - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - let ft = match entry.file_type() { - Ok(ft) => ft, - Err(_) => continue, - }; - if ft.is_file() { - total += entry.metadata().map(|m| m.len()).unwrap_or(0); - } else if ft.is_dir() { - total += dir_size(&entry.path()); - } - } - } - total -} - -/// Build a container header + params for a response container. -pub(super) fn build_response(code: u16, tx_id: u32, params: &[u32]) -> Vec { - let len = 12 + params.len() * 4; - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(3)); // Response type - buf.extend_from_slice(&pack_u16(code)); - buf.extend_from_slice(&pack_u32(tx_id)); - for &p in params { - buf.extend_from_slice(&pack_u32(p)); - } - buf -} - -/// Build a data container wrapping a payload. -pub(super) fn build_data_container(op_code: u16, tx_id: u32, payload: &[u8]) -> Vec { - let len = 12 + payload.len(); - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(2)); // Data type - buf.extend_from_slice(&pack_u16(op_code)); - buf.extend_from_slice(&pack_u32(tx_id)); - buf.extend_from_slice(payload); - buf -} - -/// Build an event container. -pub(super) fn build_event(code: EventCode, params: &[u32]) -> Vec { - let param_count = params.len().min(3); - let len = 12 + param_count * 4; - let mut buf = Vec::with_capacity(len); - buf.extend_from_slice(&pack_u32(len as u32)); - buf.extend_from_slice(&pack_u16(4)); // Event type - buf.extend_from_slice(&pack_u16(code.into())); - buf.extend_from_slice(&pack_u32(0)); // transaction_id = 0 for events - for &p in params.iter().take(param_count) { - buf.extend_from_slice(&pack_u32(p)); - } - buf -} - -/// Build an ObjectInfo payload from filesystem metadata. -pub(super) fn build_object_info( - handle: ObjectHandle, - state: &VirtualDeviceState, -) -> Option> { - let obj = state.objects.get(&handle.0)?; - let storage = state.find_storage(obj.storage_id)?; - let full_path = storage.config.backing_dir.join(&obj.rel_path); - - let metadata = std::fs::metadata(full_path).ok()?; - let filename = obj - .rel_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let is_dir = metadata.is_dir(); - let size = if is_dir { - 0u32 - } else { - metadata.len().min(u32::MAX as u64) as u32 - }; - let format: u16 = if is_dir { - ObjectFormatCode::Association.into() - } else { - let ext = obj - .rel_path - .extension() - .unwrap_or_default() - .to_string_lossy(); - ObjectFormatCode::from_extension(&ext).into() - }; - - let mut buf = Vec::with_capacity(128); - - // StorageID - buf.extend_from_slice(&pack_u32(obj.storage_id.0)); - // ObjectFormat - buf.extend_from_slice(&pack_u16(format)); - // ProtectionStatus: None (0) - buf.extend_from_slice(&pack_u16(0)); - // ObjectCompressedSize - buf.extend_from_slice(&pack_u32(size)); - // ThumbFormat: Undefined - buf.extend_from_slice(&pack_u16(ObjectFormatCode::Undefined.into())); - // ThumbCompressedSize, ThumbPixWidth, ThumbPixHeight - buf.extend_from_slice(&pack_u32(0)); - buf.extend_from_slice(&pack_u32(0)); - buf.extend_from_slice(&pack_u32(0)); - // ImagePixWidth, ImagePixHeight, ImageBitDepth - buf.extend_from_slice(&pack_u32(0)); - buf.extend_from_slice(&pack_u32(0)); - buf.extend_from_slice(&pack_u32(0)); - // ParentObject - buf.extend_from_slice(&pack_u32(obj.parent.0)); - // AssociationType - let assoc_type: u16 = if is_dir { 1 } else { 0 }; - buf.extend_from_slice(&pack_u16(assoc_type)); - // AssociationDesc - buf.extend_from_slice(&pack_u32(0)); - // SequenceNumber - buf.extend_from_slice(&pack_u32(0)); - // Filename - buf.extend_from_slice(&pack_string(&filename)); - // DateCreated: empty - buf.push(0x00); - // DateModified: empty - buf.push(0x00); - // Keywords: empty - buf.push(0x00); - - Some(buf) -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/config.rs b/vendor/mtp-rs/src/transport/virtual_device/config.rs deleted file mode 100644 index fded25f..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/config.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Configuration types for virtual MTP devices. - -use std::path::PathBuf; -use std::time::Duration; - -/// Configuration for a virtual MTP device. -/// -/// Defines the identity and storages of a virtual device that operates against -/// a local filesystem directory instead of real USB hardware. -/// -/// # Example -/// -/// ```rust -/// use std::path::PathBuf; -/// use std::time::Duration; -/// use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; -/// -/// let config = VirtualDeviceConfig { -/// manufacturer: "Google".into(), -/// model: "Virtual Pixel 9".into(), -/// serial: "virtual-001".into(), -/// storages: vec![VirtualStorageConfig { -/// description: "Internal Storage".into(), -/// capacity: 64 * 1024 * 1024 * 1024, -/// backing_dir: PathBuf::from("/tmp/mtp-test"), -/// read_only: false, -/// }], -/// supports_rename: true, -/// event_poll_interval: Duration::from_millis(50), -/// watch_backing_dirs: true, -/// }; -/// ``` -#[derive(Debug, Clone)] -pub struct VirtualDeviceConfig { - /// Manufacturer name reported by the virtual device. - pub manufacturer: String, - /// Model name reported by the virtual device. - pub model: String, - /// Serial number for the virtual device. - pub serial: String, - /// Storage configurations. At least one storage is required. - pub storages: Vec, - /// Whether the device advertises SetObjectPropValue support (rename). - pub supports_rename: bool, - /// How long `receive_interrupt` waits when no events are pending. - /// Simulates the USB interrupt endpoint blocking behavior. - /// Default: 50ms for production use. Use `Duration::ZERO` in tests - /// to avoid slowing down the test suite. - pub event_poll_interval: Duration, - /// Watch backing directories for out-of-band changes and emit MTP events. - /// When `true`, a background filesystem watcher detects files created or - /// removed directly in the backing directories (bypassing MTP) and queues - /// `ObjectAdded`/`ObjectRemoved` events. Set to `false` for tests that - /// don't need this (faster startup, no background threads). - pub watch_backing_dirs: bool, -} - -/// Configuration for a single storage within a virtual device. -#[derive(Debug, Clone)] -pub struct VirtualStorageConfig { - /// Human-readable storage description (for example, "Internal Storage"). - pub description: String, - /// Maximum storage capacity in bytes. - pub capacity: u64, - /// Local directory backing this storage. Files here become MTP objects. - pub backing_dir: PathBuf, - /// If true, write operations return `StoreReadOnly`. - pub read_only: bool, -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/handlers.rs b/vendor/mtp-rs/src/transport/virtual_device/handlers.rs deleted file mode 100644 index 85d65bc..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/handlers.rs +++ /dev/null @@ -1,1096 +0,0 @@ -//! Protocol operation handlers for the virtual device. -//! -//! Each handler processes an incoming MTP command and queues response (and optionally -//! data) containers into the state's response queue. - -use super::builders::{ - build_data_container, build_device_info, build_event, build_object_info, build_response, - build_storage_info, -}; -use super::state::{PendingSendInfo, VirtualDeviceState}; -use crate::ptp::{ - pack_string, pack_u32_array, unpack_string, EventCode, ObjectFormatCode, ObjectHandle, - ObjectPropertyCode, OperationCode, StorageId, -}; -use std::path::{Path, PathBuf}; - -/// Response code constants as u16. -const OK: u16 = 0x2001; -const GENERAL_ERROR: u16 = 0x2002; -const SESSION_NOT_OPEN: u16 = 0x2003; -const OPERATION_NOT_SUPPORTED: u16 = 0x2005; -const INVALID_STORAGE_ID: u16 = 0x2008; -const INVALID_OBJECT_HANDLE: u16 = 0x2009; -const STORE_READ_ONLY: u16 = 0x200E; -const NO_THUMBNAIL: u16 = 0x2010; -const SESSION_ALREADY_OPEN: u16 = 0x201E; -const INVALID_PARENT: u16 = 0x201A; - -/// Dispatch a command to the appropriate handler. -/// -/// `op_code` is the raw operation code, `tx_id` the transaction ID, -/// `params` are the u32 command parameters, and `data_payload` is the -/// optional data phase payload (for operations like SendObjectInfo/SendObject). -pub(super) fn dispatch( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], - data_payload: Option<&[u8]>, -) { - let op = OperationCode::from(op_code); - - match op { - OperationCode::GetDeviceInfo => handle_get_device_info(state, op_code, tx_id), - OperationCode::OpenSession => handle_open_session(state, tx_id, params), - OperationCode::CloseSession => handle_close_session(state, tx_id), - _ => { - // All other operations require an open session - if !state.session_open { - state - .response_queue - .push_back(build_response(SESSION_NOT_OPEN, tx_id, &[])); - return; - } - match op { - OperationCode::GetStorageIds => handle_get_storage_ids(state, op_code, tx_id), - OperationCode::GetStorageInfo => { - handle_get_storage_info(state, op_code, tx_id, params) - } - OperationCode::GetObjectHandles => { - handle_get_object_handles(state, op_code, tx_id, params) - } - OperationCode::GetObjectInfo => { - handle_get_object_info(state, op_code, tx_id, params) - } - OperationCode::GetObject => handle_get_object(state, op_code, tx_id, params), - OperationCode::GetPartialObject => { - handle_get_partial_object(state, op_code, tx_id, params) - } - OperationCode::GetPartialObject64 => { - handle_get_partial_object_64(state, op_code, tx_id, params) - } - OperationCode::GetThumb => handle_get_thumb(state, tx_id, params), - OperationCode::SendObjectInfo => { - handle_send_object_info(state, tx_id, params, data_payload) - } - OperationCode::SendObject => handle_send_object(state, tx_id, data_payload), - OperationCode::DeleteObject => handle_delete_object(state, tx_id, params), - OperationCode::MoveObject => handle_move_object(state, tx_id, params), - OperationCode::CopyObject => handle_copy_object(state, tx_id, params), - OperationCode::GetObjectPropValue => { - handle_get_object_prop_value(state, op_code, tx_id, params) - } - OperationCode::SetObjectPropValue => { - handle_set_object_prop_value(state, tx_id, params, data_payload) - } - _ => { - state.response_queue.push_back(build_response( - OPERATION_NOT_SUPPORTED, - tx_id, - &[], - )); - } - } - } - } -} - -fn handle_get_device_info(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32) { - let payload = build_device_info(state); - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_open_session(state: &mut VirtualDeviceState, tx_id: u32, _params: &[u32]) { - if state.session_open { - state - .response_queue - .push_back(build_response(SESSION_ALREADY_OPEN, tx_id, &[])); - } else { - state.session_open = true; - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } -} - -fn handle_close_session(state: &mut VirtualDeviceState, tx_id: u32) { - state.session_open = false; - state.objects.clear(); - state.pending_send = None; - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_get_storage_ids(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32) { - let ids: Vec = state.storages.iter().map(|s| s.storage_id.0).collect(); - let payload = pack_u32_array(&ids); - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_get_storage_info( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let storage_id = StorageId(params.first().copied().unwrap_or(0)); - match build_storage_info(state, storage_id) { - Some(payload) => { - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - None => { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - } - } -} - -fn handle_get_object_handles( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let storage_id = StorageId(params.first().copied().unwrap_or(0)); - // params[1] = format filter (ignored), params[2] = parent handle - let parent_raw = params.get(2).copied().unwrap_or(0); - let parent = ObjectHandle(parent_raw); - - // Validate storage - if state.find_storage(storage_id).is_none() { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - return; - } - - let handles_result = if parent == ObjectHandle::ALL { - state.scan_all(storage_id) - } else { - state.scan_dir(storage_id, parent) - }; - - match handles_result { - Ok(handles) => { - let raw: Vec = handles.iter().map(|h| h.0).collect(); - let payload = pack_u32_array(&raw); - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -fn handle_get_object_info( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - match build_object_info(handle, state) { - Some(payload) => { - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - } - } -} - -fn handle_get_object(state: &mut VirtualDeviceState, op_code: u16, tx_id: u32, params: &[u32]) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - match std::fs::read(path) { - Ok(data) => { - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &data)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -fn handle_get_partial_object( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let offset = params.get(1).copied().unwrap_or(0) as u64; - let max_bytes = params.get(2).copied().unwrap_or(0) as usize; - read_partial(state, op_code, tx_id, handle, offset, max_bytes); -} - -fn handle_get_partial_object_64( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let offset_lo = params.get(1).copied().unwrap_or(0) as u64; - let offset_hi = params.get(2).copied().unwrap_or(0) as u64; - let offset = (offset_hi << 32) | offset_lo; - let max_bytes = params.get(3).copied().unwrap_or(0) as usize; - read_partial(state, op_code, tx_id, handle, offset, max_bytes); -} - -fn read_partial( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - handle: ObjectHandle, - offset: u64, - max_bytes: usize, -) { - use std::io::{Read, Seek, SeekFrom}; - - let path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - match std::fs::File::open(path) { - Ok(mut file) => { - if offset > 0 && file.seek(SeekFrom::Start(offset)).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - let mut buf = vec![0u8; max_bytes]; - match file.read(&mut buf) { - Ok(n) => { - buf.truncate(n); - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &buf)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -fn handle_get_thumb(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - if !state.objects.contains_key(&handle.0) { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - } else { - // Virtual device never has thumbnails - state - .response_queue - .push_back(build_response(NO_THUMBNAIL, tx_id, &[])); - } -} - -fn handle_send_object_info( - state: &mut VirtualDeviceState, - tx_id: u32, - params: &[u32], - data_payload: Option<&[u8]>, -) { - let storage_id = StorageId(params.first().copied().unwrap_or(0)); - let parent = ObjectHandle(params.get(1).copied().unwrap_or(0)); - - // Validate storage - if state.find_storage(storage_id).is_none() { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - return; - } - - // Check read-only - if state.is_read_only(storage_id) { - state - .response_queue - .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); - return; - } - - // Validate parent (must be ROOT or a known directory handle in this storage) - if parent != ObjectHandle::ROOT && parent.0 != 0 { - match state.objects.get(&parent.0) { - Some(obj) if obj.storage_id == storage_id => { - let path = state.resolve_path(parent).unwrap(); - if !path.is_dir() { - state - .response_queue - .push_back(build_response(INVALID_PARENT, tx_id, &[])); - return; - } - } - _ => { - state - .response_queue - .push_back(build_response(INVALID_PARENT, tx_id, &[])); - return; - } - } - } - - // Parse ObjectInfo from data payload - let data = match data_payload { - Some(d) => d, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - // Parse just what we need: skip to filename at offset 52 - let info = match crate::ptp::ObjectInfo::from_bytes(data) { - Ok(i) => i, - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let is_folder = info.format == ObjectFormatCode::Association; - let new_handle = state.alloc_handle(); - - state.pending_send = Some(PendingSendInfo { - storage_id, - parent, - filename: info.filename.clone(), - size: info.size, - is_folder, - assigned_handle: new_handle, - }); - - // For folders, create immediately (no SendObject phase needed) - if is_folder { - let storage = state.find_storage(storage_id).unwrap(); - let parent_path = if parent == ObjectHandle::ROOT || parent.0 == 0 { - PathBuf::new() - } else { - state.objects.get(&parent.0).unwrap().rel_path.clone() - }; - let rel_path = parent_path.join(&info.filename); - let full_path = storage.config.backing_dir.join(&rel_path); - - if validate_path_within(&storage.config.backing_dir, &full_path).is_err() { - state.pending_send = None; - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - if std::fs::create_dir_all(&full_path).is_err() { - state.pending_send = None; - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - state.objects.insert( - new_handle.0, - super::state::VirtualObject { - rel_path, - storage_id, - parent, - }, - ); - - // Queue events - state - .event_queue - .push_back(build_event(EventCode::ObjectAdded, &[new_handle.0])); - state - .event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); - - state.pending_send = None; - } - - // Response params: storage_id, parent_handle, new_object_handle - state.response_queue.push_back(build_response( - OK, - tx_id, - &[storage_id.0, parent.0, new_handle.0], - )); -} - -fn handle_send_object(state: &mut VirtualDeviceState, tx_id: u32, data_payload: Option<&[u8]>) { - let pending = match state.pending_send.take() { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - if pending.is_folder { - // Folder was already created in SendObjectInfo; SendObject is a no-op - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - return; - } - - let data = data_payload.unwrap_or(&[]); - - let storage = match state.find_storage(pending.storage_id) { - Some(s) => s, - None => { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - return; - } - }; - - let parent_path = if pending.parent == ObjectHandle::ROOT || pending.parent.0 == 0 { - PathBuf::new() - } else { - match state.objects.get(&pending.parent.0) { - Some(obj) => obj.rel_path.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_PARENT, tx_id, &[])); - return; - } - } - }; - - let rel_path = parent_path.join(&pending.filename); - let full_path = storage.config.backing_dir.join(&rel_path); - - if validate_path_within(&storage.config.backing_dir, &full_path).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - // Ensure parent directory exists - if let Some(parent_dir) = full_path.parent() { - if std::fs::create_dir_all(parent_dir).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - } - - if std::fs::write(&full_path, data).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - let handle = pending.assigned_handle; - state.objects.insert( - handle.0, - super::state::VirtualObject { - rel_path, - storage_id: pending.storage_id, - parent: pending.parent, - }, - ); - - // Queue events - state - .event_queue - .push_back(build_event(EventCode::ObjectAdded, &[handle.0])); - state.event_queue.push_back(build_event( - EventCode::StorageInfoChanged, - &[pending.storage_id.0], - )); - - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_delete_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - - let obj = match state.objects.get(&handle.0) { - Some(o) => o.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - if state.is_read_only(obj.storage_id) { - state - .response_queue - .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); - return; - } - - let path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let result = if path.is_dir() { - std::fs::remove_dir_all(&path) - } else { - std::fs::remove_file(&path) - }; - - match result { - Ok(()) => { - let storage_id = obj.storage_id; - - // Remove this handle and any child handles - let to_remove: Vec = state - .objects - .iter() - .filter(|(_, o)| { - o.storage_id == storage_id && (o.rel_path.starts_with(&obj.rel_path)) - }) - .map(|(&h, _)| h) - .collect(); - - for h in &to_remove { - state.objects.remove(h); - } - - state - .event_queue - .push_back(build_event(EventCode::ObjectRemoved, &[handle.0])); - state - .event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); - - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -fn handle_move_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let dest_storage = StorageId(params.get(1).copied().unwrap_or(0)); - let dest_parent = ObjectHandle(params.get(2).copied().unwrap_or(0)); - - let obj = match state.objects.get(&handle.0) { - Some(o) => o.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - if state.is_read_only(obj.storage_id) || state.is_read_only(dest_storage) { - state - .response_queue - .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); - return; - } - - let src_path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let dest_storage_state = match state.find_storage(dest_storage) { - Some(s) => s, - None => { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - return; - } - }; - - let filename = obj.rel_path.file_name().unwrap_or_default().to_os_string(); - - let dest_parent_rel = if dest_parent == ObjectHandle::ROOT || dest_parent.0 == 0 { - PathBuf::new() - } else { - match state.objects.get(&dest_parent.0) { - Some(o) => o.rel_path.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_PARENT, tx_id, &[])); - return; - } - } - }; - - let new_rel = dest_parent_rel.join(filename); - let dest_path = dest_storage_state.config.backing_dir.join(&new_rel); - - if validate_path_within(&dest_storage_state.config.backing_dir, &dest_path).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - if std::fs::rename(&src_path, &dest_path).is_err() { - // Cross-device move: copy + delete - let copy_result = if src_path.is_dir() { - copy_dir_all(&src_path, &dest_path) - } else { - std::fs::copy(&src_path, &dest_path).map(|_| ()) - }; - match copy_result { - Ok(()) => { - let _ = if src_path.is_dir() { - std::fs::remove_dir_all(&src_path) - } else { - std::fs::remove_file(&src_path) - }; - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - } - } - - // Update object's storage and path - let src_storage = obj.storage_id; - if let Some(obj) = state.objects.get_mut(&handle.0) { - obj.rel_path = new_rel; - obj.storage_id = dest_storage; - obj.parent = dest_parent; - } - - state - .event_queue - .push_back(build_event(EventCode::ObjectInfoChanged, &[handle.0])); - state - .event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[src_storage.0])); - if dest_storage != src_storage { - state.event_queue.push_back(build_event( - EventCode::StorageInfoChanged, - &[dest_storage.0], - )); - } - - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_copy_object(state: &mut VirtualDeviceState, tx_id: u32, params: &[u32]) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let dest_storage = StorageId(params.get(1).copied().unwrap_or(0)); - let dest_parent = ObjectHandle(params.get(2).copied().unwrap_or(0)); - - let obj = match state.objects.get(&handle.0) { - Some(o) => o.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - if state.is_read_only(dest_storage) { - state - .response_queue - .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); - return; - } - - let src_path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let dest_storage_state = match state.find_storage(dest_storage) { - Some(s) => s, - None => { - state - .response_queue - .push_back(build_response(INVALID_STORAGE_ID, tx_id, &[])); - return; - } - }; - - let filename = obj.rel_path.file_name().unwrap_or_default().to_os_string(); - - let dest_parent_rel = if dest_parent == ObjectHandle::ROOT || dest_parent.0 == 0 { - PathBuf::new() - } else { - match state.objects.get(&dest_parent.0) { - Some(o) => o.rel_path.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_PARENT, tx_id, &[])); - return; - } - } - }; - - let new_rel = dest_parent_rel.join(filename); - let dest_path = dest_storage_state.config.backing_dir.join(&new_rel); - - if validate_path_within(&dest_storage_state.config.backing_dir, &dest_path).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - let copy_result = if src_path.is_dir() { - copy_dir_all(&src_path, &dest_path) - } else { - std::fs::copy(&src_path, &dest_path).map(|_| ()) - }; - - match copy_result { - Ok(()) => { - let new_handle = state.alloc_handle(); - state.objects.insert( - new_handle.0, - super::state::VirtualObject { - rel_path: new_rel, - storage_id: dest_storage, - parent: dest_parent, - }, - ); - - state - .event_queue - .push_back(build_event(EventCode::ObjectAdded, &[new_handle.0])); - state.event_queue.push_back(build_event( - EventCode::StorageInfoChanged, - &[dest_storage.0], - )); - - state - .response_queue - .push_back(build_response(OK, tx_id, &[new_handle.0])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -fn handle_get_object_prop_value( - state: &mut VirtualDeviceState, - op_code: u16, - tx_id: u32, - params: &[u32], -) { - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let prop_code = params.get(1).copied().unwrap_or(0) as u16; - let property = ObjectPropertyCode::from(prop_code); - - let obj = match state.objects.get(&handle.0) { - Some(o) => o.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - let payload = match property { - ObjectPropertyCode::ObjectFileName => { - let name = obj - .rel_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - pack_string(&name) - } - ObjectPropertyCode::StorageId => crate::ptp::pack_u32(obj.storage_id.0).to_vec(), - ObjectPropertyCode::ParentObject => crate::ptp::pack_u32(obj.parent.0).to_vec(), - ObjectPropertyCode::ObjectSize => { - let path = state.resolve_path(handle).unwrap(); - let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - crate::ptp::pack_u64(size).to_vec() - } - _ => { - state - .response_queue - .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); - return; - } - }; - - state - .response_queue - .push_back(build_data_container(op_code, tx_id, &payload)); - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); -} - -fn handle_set_object_prop_value( - state: &mut VirtualDeviceState, - tx_id: u32, - params: &[u32], - data_payload: Option<&[u8]>, -) { - if !state.config.supports_rename { - state - .response_queue - .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); - return; - } - - let handle = ObjectHandle(params.first().copied().unwrap_or(0)); - let prop_code = params.get(1).copied().unwrap_or(0) as u16; - let property = ObjectPropertyCode::from(prop_code); - - if property != ObjectPropertyCode::ObjectFileName { - state - .response_queue - .push_back(build_response(OPERATION_NOT_SUPPORTED, tx_id, &[])); - return; - } - - let data = match data_payload { - Some(d) => d, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let (new_name, _) = match unpack_string(data) { - Ok(v) => v, - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let obj = match state.objects.get(&handle.0) { - Some(o) => o.clone(), - None => { - state - .response_queue - .push_back(build_response(INVALID_OBJECT_HANDLE, tx_id, &[])); - return; - } - }; - - if state.is_read_only(obj.storage_id) { - state - .response_queue - .push_back(build_response(STORE_READ_ONLY, tx_id, &[])); - return; - } - - let old_path = match state.resolve_path(handle) { - Some(p) => p, - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - - let new_path = old_path.parent().unwrap().join(&new_name); - - // Find the backing dir for this object's storage - let backing_dir = match state.find_storage(obj.storage_id) { - Some(s) => s.config.backing_dir.clone(), - None => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - }; - if validate_path_within(&backing_dir, &new_path).is_err() { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - return; - } - - match std::fs::rename(&old_path, &new_path) { - Ok(()) => { - // Update the relative path - if let Some(obj_mut) = state.objects.get_mut(&handle.0) { - let parent_rel = obj_mut.rel_path.parent().map(|p| p.to_path_buf()); - obj_mut.rel_path = match parent_rel { - Some(p) if p != PathBuf::new() => p.join(&new_name), - _ => PathBuf::from(&new_name), - }; - } - - state - .response_queue - .push_back(build_response(OK, tx_id, &[])); - } - Err(_) => { - state - .response_queue - .push_back(build_response(GENERAL_ERROR, tx_id, &[])); - } - } -} - -/// Check that `path` is inside `base_dir`. Returns the canonicalized path or an error. -fn validate_path_within(base_dir: &Path, path: &Path) -> Result { - let canonical_base = base_dir.canonicalize().map_err(|_| ())?; - - // If the path exists, canonicalize it directly - if path.exists() { - let canonical = path.canonicalize().map_err(|_| ())?; - if canonical.starts_with(&canonical_base) { - return Ok(canonical); - } - return Err(()); - } - - // Path doesn't exist yet — canonicalize its parent and check - if let Some(parent) = path.parent() { - if parent.exists() { - let canonical_parent = parent.canonicalize().map_err(|_| ())?; - if canonical_parent.starts_with(&canonical_base) { - let filename = path.file_name().ok_or(())?; - // Also reject filenames containing path separators or .. - let name_str = filename.to_string_lossy(); - if name_str.contains("..") || name_str.contains('/') || name_str.contains('\\') { - return Err(()); - } - return Ok(canonical_parent.join(filename)); - } - } - } - Err(()) -} - -/// Recursively copy a directory. -fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let ft = entry.file_type()?; - let dest_path = dst.join(entry.file_name()); - if ft.is_dir() { - copy_dir_all(&entry.path(), &dest_path)?; - } else { - std::fs::copy(entry.path(), &dest_path)?; - } - } - Ok(()) -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/mod.rs b/vendor/mtp-rs/src/transport/virtual_device/mod.rs deleted file mode 100644 index f8d8f5e..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/mod.rs +++ /dev/null @@ -1,1290 +0,0 @@ -//! Virtual MTP device transport for testing. -//! -//! This module provides a [`VirtualTransport`] that implements the [`Transport`] trait -//! using a local filesystem directory as its backing store. It speaks the full MTP/PTP -//! binary protocol, so the existing `PtpSession`, `MtpDevice`, and `Storage` types -//! work unchanged. -//! -//! Use this to test MTP client code without real USB hardware. -//! -//! # Example -//! -//! ```rust,no_run -//! use std::path::PathBuf; -//! use std::time::Duration; -//! use mtp_rs::MtpDevice; -//! use mtp_rs::transport::virtual_device::config::{VirtualDeviceConfig, VirtualStorageConfig}; -//! -//! # async fn example() -> Result<(), mtp_rs::Error> { -//! let device = MtpDevice::builder() -//! .open_virtual(VirtualDeviceConfig { -//! manufacturer: "Google".into(), -//! model: "Virtual Pixel 9".into(), -//! serial: "virtual-001".into(), -//! storages: vec![VirtualStorageConfig { -//! description: "Internal Storage".into(), -//! capacity: 64 * 1024 * 1024 * 1024, -//! backing_dir: PathBuf::from("/tmp/mtp-test"), -//! read_only: false, -//! }], -//! supports_rename: true, -//! event_poll_interval: Duration::from_millis(50), -//! watch_backing_dirs: true, -//! }) -//! .await?; -//! -//! // Use the device exactly like a real one -//! for storage in device.storages().await? { -//! for obj in storage.list_objects(None).await? { -//! println!("{}", obj.filename); -//! } -//! } -//! # Ok(()) -//! # } -//! ``` - -mod builders; -pub mod config; -mod handlers; -pub mod registry; -mod state; -mod watcher; - -use crate::ptp::{unpack_u16, unpack_u32}; -use crate::transport::Transport; -use async_trait::async_trait; -use config::VirtualDeviceConfig; -pub use state::RescanSummary; -use state::{PendingCommand, VirtualDeviceState}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// A transport that speaks MTP/PTP binary protocol against a local filesystem. -/// -/// Created via `MtpDeviceBuilder::open_virtual()` or directly for lower-level use. -/// -/// Internally, incoming `send_bulk` calls are parsed as MTP command/data containers. -/// The virtual device processes each operation (list files, read, write, delete, etc.) -/// against the configured backing directories and queues binary response containers -/// for the next `receive_bulk` call. -/// -/// A background filesystem watcher detects out-of-band changes to the backing -/// directories and queues corresponding MTP events. The watcher is stopped -/// automatically when the transport is dropped. -pub struct VirtualTransport { - state: Arc>, - /// How long `receive_interrupt` waits when no events are pending. - event_poll_interval: Duration, - /// Serial number, used to unregister from the active-states registry on drop. - serial: String, - /// Filesystem watcher. Stops watching when dropped. - _watcher: Option, -} - -impl VirtualTransport { - /// Create a new virtual transport from a device configuration. - /// - /// The backing directories in each storage config should already exist. - /// When `config.watch_backing_dirs` is `true`, starts a background - /// filesystem watcher for detecting out-of-band changes. - #[must_use] - pub fn new(config: VirtualDeviceConfig) -> Self { - let event_poll_interval = config.event_poll_interval; - let watch = config.watch_backing_dirs; - let serial = config.serial.clone(); - let state = Arc::new(Mutex::new(VirtualDeviceState::new(config))); - - // Register the state so `rescan_virtual_device()` can find it. - registry::register_active_state(serial.clone(), Arc::clone(&state)); - - let watcher = if watch { - watcher::start_fs_watcher(&state) - } else { - None - }; - Self { - state, - event_poll_interval, - serial, - _watcher: watcher, - } - } -} - -impl Drop for VirtualTransport { - fn drop(&mut self) { - registry::unregister_active_state(&self.serial); - } -} - -/// Container type constants. -const CONTAINER_TYPE_COMMAND: u16 = 1; -const CONTAINER_TYPE_DATA: u16 = 2; - -#[async_trait] -impl Transport for VirtualTransport { - async fn send_bulk(&self, data: &[u8]) -> Result<(), crate::Error> { - if data.len() < 12 { - return Err(crate::Error::invalid_data("container too small")); - } - - let _length = unpack_u32(&data[0..4])?; - let container_type = unpack_u16(&data[4..6])?; - let code = unpack_u16(&data[6..8])?; - let tx_id = unpack_u32(&data[8..12])?; - - let mut state = self.state.lock().unwrap(); - - match container_type { - CONTAINER_TYPE_COMMAND => { - // Parse parameters (each u32, after the 12-byte header) - let param_bytes = data.len() - 12; - let param_count = param_bytes / 4; - let mut params = Vec::with_capacity(param_count); - for i in 0..param_count { - let offset = 12 + i * 4; - params.push(unpack_u32(&data[offset..])?); - } - - // Check if this operation expects a data phase from the host. - // If so, don't dispatch yet -- store the command and wait for data. - let op = crate::ptp::OperationCode::from(code); - if matches!( - op, - crate::ptp::OperationCode::SendObjectInfo - | crate::ptp::OperationCode::SendObject - | crate::ptp::OperationCode::SetObjectPropValue - ) { - state.pending_command = Some(PendingCommand { - code, - tx_id, - params, - }); - } else { - handlers::dispatch(&mut state, code, tx_id, ¶ms, None); - } - } - CONTAINER_TYPE_DATA => { - // This is the data phase for a previous command. - match state.pending_command.take() { - Some(pending) => { - let payload = &data[12..]; // Skip data container header - handlers::dispatch( - &mut state, - pending.code, - pending.tx_id, - &pending.params, - Some(payload), - ); - } - None => { - return Err(crate::Error::invalid_data( - "received data container without pending command", - )); - } - } - } - _ => { - return Err(crate::Error::invalid_data(format!( - "unexpected container type: {}", - container_type - ))); - } - } - - Ok(()) - } - - async fn receive_bulk(&self, _max_size: usize) -> Result, crate::Error> { - let mut state = self.state.lock().unwrap(); - match state.response_queue.pop_front() { - Some(data) => Ok(data), - None => Err(crate::Error::invalid_data("no response available")), - } - } - - async fn receive_interrupt(&self) -> Result, crate::Error> { - // Check for events (drop lock before any await) - { - let mut state = self.state.lock().unwrap(); - if let Some(event) = state.event_queue.pop_front() { - return Ok(event); - } - } - // No events — wait, then return Timeout - futures_timer::Delay::new(self.event_poll_interval).await; - Err(crate::Error::Timeout) - } - - async fn cancel_transfer( - &self, - _transaction_id: u32, - _idle_timeout: std::time::Duration, - ) -> Result<(), crate::Error> { - // Virtual device has no USB pipe to drain — just clear any pending state. - let mut state = self.state.lock().unwrap(); - state.pending_command = None; - state.response_queue.clear(); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::config::{VirtualDeviceConfig, VirtualStorageConfig}; - use crate::mtp::MtpDevice; - use crate::ptp::ObjectFormatCode; - use std::time::Duration; - - fn test_config(dir: &std::path::Path) -> VirtualDeviceConfig { - VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-001".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, // 1 GB - backing_dir: dir.to_path_buf(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - } - } - - fn test_config_readonly(dir: &std::path::Path) -> VirtualDeviceConfig { - VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-ro".into(), - storages: vec![VirtualStorageConfig { - description: "Read-only Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: dir.to_path_buf(), - read_only: true, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - } - } - - fn test_config_multi(dirs: &[&std::path::Path]) -> VirtualDeviceConfig { - VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-multi".into(), - storages: dirs - .iter() - .enumerate() - .map(|(i, d)| VirtualStorageConfig { - description: format!("Storage {}", i + 1), - capacity: 1024 * 1024 * 1024, - backing_dir: d.to_path_buf(), - read_only: false, - }) - .collect(), - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - } - } - - /// Helper to convert `&[u8]` to a `Stream>`. - fn bytes_stream( - data: &[u8], - ) -> impl futures::Stream> + Unpin { - futures::stream::once(futures::future::ok(bytes::Bytes::copy_from_slice(data))) - } - - #[tokio::test] - async fn open_virtual_and_list_storages() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - assert_eq!(storages.len(), 1); - assert_eq!(storages[0].info().description, "Internal Storage"); - assert!(storages[0].info().max_capacity > 0); - } - - #[tokio::test] - async fn device_info_matches_config() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let info = device.device_info(); - - assert_eq!(info.manufacturer, "TestCorp"); - assert_eq!(info.model, "Virtual Phone"); - assert_eq!(info.serial_number, "test-001"); - assert!(device.supports_rename()); - } - - #[tokio::test] - async fn list_objects_empty_dir() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let objects = storages[0].list_objects(None).await.unwrap(); - - assert!(objects.is_empty()); - } - - #[tokio::test] - async fn list_objects_with_files() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); - std::fs::write(dir.path().join("photo.jpg"), "fake jpeg data").unwrap(); - std::fs::create_dir(dir.path().join("Documents")).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let items = storages[0].list_objects(None).await.unwrap(); - - assert_eq!(items.len(), 3); - let names: Vec<&str> = items.iter().map(|i| i.filename.as_str()).collect(); - assert!(names.contains(&"hello.txt")); - assert!(names.contains(&"photo.jpg")); - assert!(names.contains(&"Documents")); - - // Verify folder detection - let docs = items.iter().find(|i| i.filename == "Documents").unwrap(); - assert!(docs.is_folder()); - assert_eq!(docs.format, ObjectFormatCode::Association); - - // Verify file metadata - let txt = items.iter().find(|i| i.filename == "hello.txt").unwrap(); - assert!(txt.is_file()); - assert_eq!(txt.size, 11); // "hello world" = 11 bytes - } - - #[tokio::test] - async fn download_file() { - let dir = tempfile::tempdir().unwrap(); - let content = b"test file content for download"; - std::fs::write(dir.path().join("test.txt"), content).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - let data = storages[0].download(obj.handle).await.unwrap(); - assert_eq!(data.as_slice(), content); - } - - #[tokio::test] - async fn download_partial_reads_byte_ranges() { - let dir = tempfile::tempdir().unwrap(); - let content: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - std::fs::write(dir.path().join("data.bin"), &content).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - // Read from the beginning. - let head = storages[0] - .download_partial(obj.handle, 0, 100) - .await - .unwrap(); - assert_eq!(head, &content[0..100]); - - // Read from the middle. - let middle = storages[0] - .download_partial(obj.handle, 500, 100) - .await - .unwrap(); - assert_eq!(middle, &content[500..600]); - - // Read past the end: device returns whatever's left. - let tail = storages[0] - .download_partial(obj.handle, 990, 1000) - .await - .unwrap(); - assert_eq!(tail, &content[990..1000]); - } - - #[tokio::test] - async fn download_partial_64_reads_byte_ranges() { - let dir = tempfile::tempdir().unwrap(); - let content: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - std::fs::write(dir.path().join("data.bin"), &content).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - // Same scenarios as the 32-bit version, using the 64-bit op. - let head = storages[0] - .download_partial_64(obj.handle, 0, 100) - .await - .unwrap(); - assert_eq!(head, &content[0..100]); - - let middle = storages[0] - .download_partial_64(obj.handle, 500, 100) - .await - .unwrap(); - assert_eq!(middle, &content[500..600]); - - let tail = storages[0] - .download_partial_64(obj.handle, 990, 1000) - .await - .unwrap(); - assert_eq!(tail, &content[990..1000]); - } - - #[tokio::test] - async fn download_partial_64_reassembles_offset_correctly() { - // Verifies the lo/hi u32 → u64 round-trip. We can't realistically create a >4GB - // file, so instead we test that an offset whose low u32 is 0 (i.e. an exact - // multiple of 2^32) routes through the same code path with no truncation. - // For a small file, any offset >= file length returns an empty read, which - // still proves the u64 offset made it through correctly (if the high bits - // were dropped, we'd incorrectly read from the start of the file). - let dir = tempfile::tempdir().unwrap(); - let content: Vec = (0..100).map(|i| i as u8).collect(); - std::fs::write(dir.path().join("small.bin"), &content).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - // Offset = 2^32 + 10. If the hi u32 were dropped, this would read from byte 10. - let offset_beyond_4gb: u64 = (1u64 << 32) + 10; - let data = storages[0] - .download_partial_64(obj.handle, offset_beyond_4gb, 50) - .await - .unwrap(); - assert!( - data.is_empty(), - "offset past EOF should return empty, got {} bytes — high offset bits may be getting dropped", - data.len() - ); - } - - #[tokio::test] - async fn upload_file() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let info = crate::mtp::NewObjectInfo::file("uploaded.txt", 12); - let handle = storages[0] - .upload(None, info, bytes_stream(b"hello upload")) - .await - .unwrap(); - - // Verify file exists on disk - let path = dir.path().join("uploaded.txt"); - assert!(path.exists()); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello upload"); - - // Verify we can download it back - let data = storages[0].download(handle).await.unwrap(); - assert_eq!(data.as_slice(), b"hello upload"); - } - - #[tokio::test] - async fn upload_to_subfolder() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join("Music")).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // List root to get the Music folder handle - let items = storages[0].list_objects(None).await.unwrap(); - let music = items.iter().find(|i| i.filename == "Music").unwrap(); - assert!(music.is_folder()); - - // Upload a file into Music - let info = crate::mtp::NewObjectInfo::file("song.mp3", 5); - storages[0] - .upload(Some(music.handle), info, bytes_stream(b"audio")) - .await - .unwrap(); - - assert!(dir.path().join("Music/song.mp3").exists()); - assert_eq!( - std::fs::read_to_string(dir.path().join("Music/song.mp3")).unwrap(), - "audio" - ); - } - - #[tokio::test] - async fn delete_file() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("doomed.txt"), "goodbye").unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - storages[0].delete(obj.handle).await.unwrap(); - assert!(!dir.path().join("doomed.txt").exists()); - } - - #[tokio::test] - async fn create_folder() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - storages[0].create_folder(None, "NewFolder").await.unwrap(); - - assert!(dir.path().join("NewFolder").is_dir()); - } - - #[tokio::test] - async fn rename_file() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("old_name.txt"), "content").unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let items = storages[0].list_objects(None).await.unwrap(); - let obj = &items[0]; - - storages[0] - .rename(obj.handle, "new_name.txt") - .await - .unwrap(); - - assert!(!dir.path().join("old_name.txt").exists()); - assert!(dir.path().join("new_name.txt").exists()); - assert_eq!( - std::fs::read_to_string(dir.path().join("new_name.txt")).unwrap(), - "content" - ); - } - - #[tokio::test] - async fn nested_directories() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap(); - std::fs::write(dir.path().join("a/b/c/deep.txt"), "deep").unwrap(); - std::fs::write(dir.path().join("a/top.txt"), "top").unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // List root - let root_items = storages[0].list_objects(None).await.unwrap(); - assert_eq!(root_items.len(), 1); // Only "a" - assert_eq!(root_items[0].filename, "a"); - assert!(root_items[0].is_folder()); - } - - #[tokio::test] - async fn read_only_storage_rejects_writes() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("existing.txt"), "data").unwrap(); - - let config = test_config_readonly(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // Verify read-only access capability is reported - assert_ne!( - storages[0].info().access_capability, - crate::ptp::AccessCapability::ReadWrite - ); - - // Upload should fail - let info = crate::mtp::NewObjectInfo::file("new.txt", 4); - let result = storages[0].upload(None, info, bytes_stream(b"data")).await; - assert!(result.is_err()); - - // Delete should fail - let items = storages[0].list_objects(None).await.unwrap(); - let result = storages[0].delete(items[0].handle).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn multiple_storages() { - let dir1 = tempfile::tempdir().unwrap(); - let dir2 = tempfile::tempdir().unwrap(); - std::fs::write(dir1.path().join("file1.txt"), "storage1").unwrap(); - std::fs::write(dir2.path().join("file2.txt"), "storage2").unwrap(); - - let config = test_config_multi(&[dir1.path(), dir2.path()]); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - assert_eq!(storages.len(), 2); - - let items1 = storages[0].list_objects(None).await.unwrap(); - assert_eq!(items1.len(), 1); - assert_eq!(items1[0].filename, "file1.txt"); - - let items2 = storages[1].list_objects(None).await.unwrap(); - assert_eq!(items2.len(), 1); - assert_eq!(items2[0].filename, "file2.txt"); - } - - #[tokio::test] - async fn free_space_reflects_content() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let free_before = storages[0].info().free_space_bytes; - - // Upload a file - let info = crate::mtp::NewObjectInfo::file("big.bin", 1000); - let data = vec![0u8; 1000]; - storages[0] - .upload(None, info, bytes_stream(&data)) - .await - .unwrap(); - - // Re-fetch storage info - let storages2 = device.storages().await.unwrap(); - let free_after = storages2[0].info().free_space_bytes; - - assert!(free_after < free_before); - assert_eq!(free_before - free_after, 1000); - } - - #[tokio::test] - async fn events_generated_on_upload() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let info = crate::mtp::NewObjectInfo::file("event_test.txt", 5); - storages[0] - .upload(None, info, bytes_stream(b"hello")) - .await - .unwrap(); - - // Events should be available (ObjectAdded + StorageInfoChanged) - use tokio::time::{timeout, Duration}; - let event = timeout(Duration::from_millis(100), device.next_event()).await; - assert!(event.is_ok()); - } - - #[tokio::test] - async fn events_generated_on_delete() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("to_delete.txt"), "bye").unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let items = storages[0].list_objects(None).await.unwrap(); - storages[0].delete(items[0].handle).await.unwrap(); - - // Should have ObjectRemoved + StorageInfoChanged events - use tokio::time::{timeout, Duration}; - let event = timeout(Duration::from_millis(100), device.next_event()).await; - assert!(event.is_ok()); - } - - #[tokio::test] - async fn no_events_returns_timeout() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - - // No operations performed, so no events - let result = device.next_event().await; - assert!(matches!(result, Err(crate::Error::Timeout))); - } - - #[tokio::test] - async fn copy_object() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("original.txt"), "copy me").unwrap(); - std::fs::create_dir(dir.path().join("dest")).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let items = storages[0].list_objects(None).await.unwrap(); - let original = items.iter().find(|i| i.filename == "original.txt").unwrap(); - let dest = items.iter().find(|i| i.filename == "dest").unwrap(); - - storages[0] - .copy_object(original.handle, dest.handle, None) - .await - .unwrap(); - - // Both should exist - assert!(dir.path().join("original.txt").exists()); - assert!(dir.path().join("dest/original.txt").exists()); - assert_eq!( - std::fs::read_to_string(dir.path().join("dest/original.txt")).unwrap(), - "copy me" - ); - } - - #[tokio::test] - async fn path_traversal_rejected() { - let dir = tempfile::tempdir().unwrap(); - let config = test_config(dir.path()); - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // Try to upload a file with ".." in the name - let info = crate::mtp::NewObjectInfo::file("../escape.txt", 6); - let result = storages[0] - .upload(None, info, bytes_stream(b"escape")) - .await; - assert!(result.is_err(), "path traversal upload should be rejected"); - - // Verify the file was NOT created outside the backing dir - assert!(!dir.path().parent().unwrap().join("escape.txt").exists()); - } - - #[tokio::test] - async fn move_object() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("moveme.txt"), "move me").unwrap(); - std::fs::create_dir(dir.path().join("target")).unwrap(); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - let items = storages[0].list_objects(None).await.unwrap(); - let moveme = items.iter().find(|i| i.filename == "moveme.txt").unwrap(); - let target = items.iter().find(|i| i.filename == "target").unwrap(); - - storages[0] - .move_object(moveme.handle, target.handle, None) - .await - .unwrap(); - - assert!(!dir.path().join("moveme.txt").exists()); - assert!(dir.path().join("target/moveme.txt").exists()); - assert_eq!( - std::fs::read_to_string(dir.path().join("target/moveme.txt")).unwrap(), - "move me" - ); - - // Should emit ObjectInfoChanged + StorageInfoChanged events - use tokio::time::{timeout, Duration}; - let event = timeout(Duration::from_millis(100), device.next_event()) - .await - .unwrap() - .unwrap(); - assert!(matches!( - event, - crate::mtp::DeviceEvent::ObjectInfoChanged { .. } - )); - let event = timeout(Duration::from_millis(100), device.next_event()) - .await - .unwrap() - .unwrap(); - assert!(matches!( - event, - crate::mtp::DeviceEvent::StorageInfoChanged { .. } - )); - } - - /// Helper: poll for an event, retrying on Timeout up to the deadline. - async fn poll_event_with_retry( - device: &MtpDevice, - timeout_duration: std::time::Duration, - ) -> Option { - let deadline = tokio::time::Instant::now() + timeout_duration; - loop { - match device.next_event().await { - Ok(event) => return Some(event), - Err(crate::Error::Timeout) => { - if tokio::time::Instant::now() >= deadline { - return None; - } - } - Err(_) => return None, - } - } - } - - #[tokio::test] - async fn fs_watcher_detects_file_creation() { - let dir = tempfile::tempdir().unwrap(); - // Canonicalize the backing dir to avoid macOS /var vs /private/var mismatches - let backing_dir = dir.path().canonicalize().unwrap(); - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-fswatch".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: backing_dir.clone(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::from_millis(50), - watch_backing_dirs: true, - }; - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - - // Write a file directly to the backing dir (bypassing MTP) - std::fs::write(backing_dir.join("external.txt"), "hello from outside").unwrap(); - - // Poll for events — the watcher should detect the file creation. - let event = poll_event_with_retry(&device, Duration::from_secs(5)).await; - assert!( - event.is_some(), - "expected event from fs watcher, got nothing" - ); - let event = event.unwrap(); - assert!( - matches!(event, crate::mtp::DeviceEvent::ObjectAdded { .. }), - "expected ObjectAdded, got {:?}", - event - ); - } - - /// Helper: create a virtual device with a pre-existing subdirectory and - /// return (device, backing_dir, tempdir-guard). - async fn virtual_device_with_subdirectory( - serial: &str, - ) -> (MtpDevice, std::path::PathBuf, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let backing_dir = dir.path().canonicalize().unwrap(); - std::fs::create_dir(backing_dir.join("Music")).unwrap(); - - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: serial.into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: backing_dir.clone(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::from_millis(50), - watch_backing_dirs: true, - }; - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - - // Drain any startup events (macOS FSEvents may report the watched dir). - while poll_event_with_retry(&device, Duration::from_millis(500)) - .await - .is_some() - {} - - (device, backing_dir, dir) - } - - #[tokio::test] - async fn fs_watcher_detects_file_creation_in_subdirectory() { - let (device, backing_dir, _dir) = - virtual_device_with_subdirectory("test-fswatch-subdir-create").await; - - std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); - - let event = poll_event_with_retry(&device, Duration::from_secs(5)).await; - assert!( - event.is_some(), - "expected event from fs watcher for file in subdirectory, got nothing" - ); - assert!( - matches!(event.unwrap(), crate::mtp::DeviceEvent::ObjectAdded { .. }), - "expected ObjectAdded" - ); - } - - #[tokio::test] - async fn fs_watcher_detects_file_rename_in_subdirectory() { - let (device, backing_dir, _dir) = - virtual_device_with_subdirectory("test-fswatch-subdir-rename").await; - - // Create a file and drain its events. - std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); - while poll_event_with_retry(&device, Duration::from_secs(5)) - .await - .is_some() - {} - - // Rename the file within the subdirectory. - std::fs::rename( - backing_dir.join("Music/song.mp3"), - backing_dir.join("Music/track.mp3"), - ) - .unwrap(); - - // A rename should produce an ObjectRemoved (old name) and ObjectAdded (new name). - let mut events = Vec::new(); - while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { - events.push(event); - let has_removed = events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })); - let has_added = events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })); - if has_removed && has_added { - break; - } - } - assert!( - events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), - "expected ObjectRemoved for rename source, got {:?}", - events - ); - assert!( - events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })), - "expected ObjectAdded for rename target, got {:?}", - events - ); - } - - #[tokio::test] - async fn fs_watcher_detects_file_removal_in_subdirectory() { - let (device, backing_dir, _dir) = - virtual_device_with_subdirectory("test-fswatch-subdir-remove").await; - - // Create a file and drain its events. - std::fs::write(backing_dir.join("Music/song.mp3"), "fake mp3 data").unwrap(); - while poll_event_with_retry(&device, Duration::from_secs(5)) - .await - .is_some() - {} - - // Delete the file. - std::fs::remove_file(backing_dir.join("Music/song.mp3")).unwrap(); - - let mut events = Vec::new(); - while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { - events.push(event); - if events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })) - { - break; - } - } - assert!( - events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), - "expected ObjectRemoved for file in subdirectory, got {:?}", - events - ); - } - - #[tokio::test] - async fn fs_watcher_detects_file_removal() { - let dir = tempfile::tempdir().unwrap(); - let backing_dir = dir.path().canonicalize().unwrap(); - - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-fswatch-rm".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: backing_dir.clone(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::from_millis(50), - watch_backing_dirs: true, - }; - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - - // Create the file AFTER the watcher is running, so we get a clean event sequence - std::fs::write(backing_dir.join("will_be_removed.txt"), "bye").unwrap(); - - // Drain events until no more arrive (consume the ObjectAdded from creation) - while poll_event_with_retry(&device, Duration::from_millis(500)) - .await - .is_some() - {} - - // Now remove the file directly (bypassing MTP) - std::fs::remove_file(backing_dir.join("will_be_removed.txt")).unwrap(); - - // Collect all events and look for ObjectRemoved - let mut events = Vec::new(); - while let Some(event) = poll_event_with_retry(&device, Duration::from_secs(5)).await { - events.push(event); - // Stop after we find what we need or have collected enough - if events.len() >= 10 { - break; - } - if events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })) - { - break; - } - } - - assert!( - events - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), - "expected ObjectRemoved among events, got {:?}", - events - ); - } - - #[tokio::test] - async fn fs_watcher_dedup_suppresses_mtp_events() { - let dir = tempfile::tempdir().unwrap(); - let backing_dir = dir.path().canonicalize().unwrap(); - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: "test-fswatch-dedup".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: backing_dir.clone(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::from_millis(50), - watch_backing_dirs: true, - }; - - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // Upload via MTP — should produce exactly the MTP-generated events - let info = crate::mtp::NewObjectInfo::file("dedup_test.txt", 5); - storages[0] - .upload(None, info, bytes_stream(b"hello")) - .await - .unwrap(); - - // Drain all events with a generous window for the watcher to fire. - // MTP upload produces 1 ObjectAdded + 1 StorageInfoChanged. - // The watcher sees the file creation but finds the handle already exists - // in state.objects (inserted by the MTP handler under the mutex), so it - // skips the event — no duplicate ObjectAdded. - // We count ObjectAdded specifically because some platforms (Linux inotify) - // may generate additional filesystem events (StorageInfoChanged etc.). - let mut object_added_count = 0; - let mut total_events = 0; - while let Some(event) = poll_event_with_retry(&device, Duration::from_millis(500)).await { - if matches!(event, crate::mtp::DeviceEvent::ObjectAdded { .. }) { - object_added_count += 1; - } - total_events += 1; - if total_events > 10 { - break; - } - } - - // Exactly 1 ObjectAdded from the MTP handler. The watcher's dedup - // suppresses duplicates. - assert_eq!( - object_added_count, 1, - "expected exactly 1 ObjectAdded event, got {} (dedup may have failed)", - object_added_count - ); - } - - fn test_config_with_serial(dir: &std::path::Path, serial: &str) -> VirtualDeviceConfig { - VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: serial.into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: dir.to_path_buf(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - } - } - - #[tokio::test] - async fn rescan_detects_external_changes() { - let dir = tempfile::tempdir().unwrap(); - // Create initial files before opening the device. - std::fs::write(dir.path().join("existing.txt"), "hello").unwrap(); - std::fs::create_dir(dir.path().join("Photos")).unwrap(); - std::fs::write(dir.path().join("Photos/pic.jpg"), "jpeg data").unwrap(); - - let config = test_config_with_serial(dir.path(), "rescan-test-001"); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // List all objects to populate the in-memory tree. - let root_items = storages[0].list_objects(None).await.unwrap(); - assert_eq!(root_items.len(), 2); // existing.txt + Photos - let photos = root_items.iter().find(|i| i.filename == "Photos").unwrap(); - let _sub_items = storages[0].list_objects(Some(photos.handle)).await.unwrap(); - - // Drain any events from listing. - while device.next_event().await.is_ok() {} - - // --- Externally modify the backing dir (bypassing MTP) --- - // Delete existing.txt - std::fs::remove_file(dir.path().join("existing.txt")).unwrap(); - // Create a new file - std::fs::write(dir.path().join("new_file.txt"), "I'm new").unwrap(); - // Delete the file inside Photos - std::fs::remove_file(dir.path().join("Photos/pic.jpg")).unwrap(); - - // Rescan via the public API. - let summary = crate::rescan_virtual_device("rescan-test-001").unwrap(); - assert_eq!( - summary.removed, 2, - "should remove existing.txt and Photos/pic.jpg" - ); - assert_eq!(summary.added, 1, "should add new_file.txt"); - - // Verify the object tree now matches the filesystem. - let root_items = storages[0].list_objects(None).await.unwrap(); - let names: Vec<&str> = root_items.iter().map(|i| i.filename.as_str()).collect(); - assert!( - names.contains(&"new_file.txt"), - "new_file.txt should appear: {:?}", - names - ); - assert!( - names.contains(&"Photos"), - "Photos dir still exists: {:?}", - names - ); - assert!( - !names.contains(&"existing.txt"), - "existing.txt should be gone: {:?}", - names - ); - - // Verify Photos subdirectory is now empty. - let photos = root_items.iter().find(|i| i.filename == "Photos").unwrap(); - let sub_items = storages[0].list_objects(Some(photos.handle)).await.unwrap(); - assert!( - sub_items.is_empty(), - "Photos should be empty after pic.jpg was removed" - ); - - // Verify events were queued (ObjectRemoved, ObjectAdded, StorageInfoChanged). - let mut event_types = Vec::new(); - loop { - match device.next_event().await { - Ok(e) => event_types.push(e), - Err(crate::Error::Timeout) => break, - Err(_) => break, - } - } - assert!( - event_types - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectAdded { .. })), - "expected ObjectAdded event from rescan" - ); - assert!( - event_types - .iter() - .any(|e| matches!(e, crate::mtp::DeviceEvent::ObjectRemoved { .. })), - "expected ObjectRemoved event from rescan" - ); - } - - #[test] - fn rescan_nonexistent_serial_returns_none() { - assert!( - crate::transport::virtual_device::registry::rescan_virtual_device("no-such-device") - .is_none() - ); - } - - #[tokio::test] - async fn rescan_no_changes_returns_zero() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("stable.txt"), "unchanged").unwrap(); - - let config = test_config_with_serial(dir.path(), "rescan-test-002"); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - - // Populate the object tree. - let _ = storages[0].list_objects(None).await.unwrap(); - - let summary = crate::rescan_virtual_device("rescan-test-002").unwrap(); - assert_eq!(summary.added, 0); - assert_eq!(summary.removed, 0); - - drop(device); - } - - #[tokio::test] - async fn list_objects_resolves_full_size_for_files_larger_than_4gb() { - // Create a 5 GB sparse file on the backing filesystem. Its ObjectInfo size - // field will saturate at u32::MAX; the real u64 size must be resolved via - // GetObjectPropValue(ObjectSize). - const REAL_SIZE: u64 = 5 * 1024 * 1024 * 1024; - - let dir = tempfile::tempdir().unwrap(); - let big_path = dir.path().join("movie.mkv"); - let file = std::fs::File::create(&big_path).unwrap(); - file.set_len(REAL_SIZE).unwrap(); - drop(file); - - let config = test_config(dir.path()); - let device = MtpDevice::builder().open_virtual(config).await.unwrap(); - let storages = device.storages().await.unwrap(); - let objects = storages[0].list_objects(None).await.unwrap(); - - let movie = objects - .iter() - .find(|o| o.filename == "movie.mkv") - .expect("movie.mkv not found in listing"); - assert_eq!( - movie.size, REAL_SIZE, - "full u64 size should be resolved for files >4 GB" - ); - } -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/registry.rs b/vendor/mtp-rs/src/transport/virtual_device/registry.rs deleted file mode 100644 index 1215209..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/registry.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! Global registry of virtual devices for discovery integration. -//! -//! Virtual devices registered here appear in `MtpDevice::list_devices()` and -//! can be opened via `open_by_location()` or `open_by_serial()`. - -use super::config::VirtualDeviceConfig; -use super::state::{RescanSummary, VirtualDeviceState}; -use crate::mtp::MtpDeviceInfo; -use std::sync::{Arc, Mutex, OnceLock}; - -/// Base for synthetic location IDs (high range, won't collide with real USB). -const VIRTUAL_LOCATION_BASE: u64 = 0xFFFF_0000_0000_0000; - -/// A registered virtual device. -struct VirtualRegistration { - info: MtpDeviceInfo, - config: VirtualDeviceConfig, -} - -/// Holds registered devices and a monotonically increasing index for unique location IDs. -struct Registry { - devices: Vec, - next_index: u64, -} - -/// Access the global registry singleton. -fn registry() -> &'static Mutex { - static REGISTRY: OnceLock> = OnceLock::new(); - REGISTRY.get_or_init(|| { - Mutex::new(Registry { - devices: Vec::new(), - next_index: 0, - }) - }) -} - -/// Register a virtual device so it appears in `MtpDevice::list_devices()`. -/// -/// Returns the `MtpDeviceInfo` with a synthetic location ID. Use this location -/// ID with `MtpDevice::open_by_location()` or the serial with -/// `MtpDevice::open_by_serial()` to open the device. -#[must_use] -pub fn register_virtual_device(config: &VirtualDeviceConfig) -> MtpDeviceInfo { - let mut reg = registry().lock().unwrap(); - let index = reg.next_index; - reg.next_index += 1; - let location_id = VIRTUAL_LOCATION_BASE + index; - - let info = MtpDeviceInfo { - vendor_id: 0xFFFF, - product_id: 0x0001, - manufacturer: Some(config.manufacturer.clone()), - product: Some(config.model.clone()), - serial_number: Some(config.serial.clone()), - location_id, - }; - - reg.devices.push(VirtualRegistration { - info: info.clone(), - config: config.clone(), - }); - - info -} - -/// Remove a registered virtual device by location ID. -pub fn unregister_virtual_device(location_id: u64) { - let mut reg = registry().lock().unwrap(); - reg.devices.retain(|r| r.info.location_id != location_id); -} - -/// Get all registered virtual devices (called by `list_devices`). -pub(crate) fn list_virtual_devices() -> Vec { - let reg = registry().lock().unwrap(); - reg.devices.iter().map(|r| r.info.clone()).collect() -} - -/// Try to find a virtual device config by location ID. -pub(crate) fn find_virtual_config_by_location(location_id: u64) -> Option { - let reg = registry().lock().unwrap(); - reg.devices - .iter() - .find(|r| r.info.location_id == location_id) - .map(|r| r.config.clone()) -} - -/// Try to find a virtual device config by serial number. -pub(crate) fn find_virtual_config_by_serial(serial: &str) -> Option { - let reg = registry().lock().unwrap(); - reg.devices - .iter() - .find(|r| r.info.serial_number.as_deref() == Some(serial)) - .map(|r| r.config.clone()) -} - -// --- Active device state registry --- -// -// When a VirtualTransport is created, it registers its shared state here so -// that `rescan_virtual_device()` can look it up by serial number. Entries are -// removed when the transport is dropped. - -/// An entry in the active-states registry: (serial, shared state). -type ActiveEntry = (String, Arc>); - -/// Access the global active-states registry. -fn active_states() -> &'static Mutex> { - static ACTIVE: OnceLock>> = OnceLock::new(); - ACTIVE.get_or_init(|| Mutex::new(Vec::new())) -} - -/// Register an active virtual device's state (called by `VirtualTransport::new`). -pub(super) fn register_active_state(serial: String, state: Arc>) { - let mut active = active_states().lock().unwrap(); - active.push((serial, state)); -} - -/// Unregister an active virtual device's state (called when `VirtualTransport` is dropped). -pub(super) fn unregister_active_state(serial: &str) { - let mut active = active_states().lock().unwrap(); - if let Some(pos) = active.iter().position(|(s, _)| s == serial) { - active.remove(pos); - } -} - -/// RAII guard that resumes the filesystem watcher when dropped. -/// -/// Created by [`pause_watcher`]. While this guard is alive, all filesystem -/// events for the device are silently dropped. -pub struct WatcherGuard { - state: Arc>, -} - -impl Drop for WatcherGuard { - fn drop(&mut self) { - if let Ok(mut state) = self.state.lock() { - state.watcher_paused = false; - } - } -} - -/// Pause the filesystem watcher for a virtual device, returning a guard that -/// resumes it on drop. -/// -/// While paused, filesystem events are silently dropped. This prevents stale -/// OS-level events from corrupting the object tree when files in the backing -/// directory are deleted and recreated externally (outside of MTP). Without -/// pausing, delayed deletion events can arrive after a rescan has already -/// re-added the objects, incorrectly removing them. -/// -/// Returns `None` if no active virtual device with that serial exists. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::{pause_watcher, rescan_virtual_device}; -/// -/// { -/// let _guard = pause_watcher("my-device-serial").unwrap(); -/// -/// // ... delete and recreate files in the backing directory ... -/// -/// rescan_virtual_device("my-device-serial"); -/// } // watcher resumes here when `_guard` is dropped -/// ``` -pub fn pause_watcher(serial: &str) -> Option { - let active = active_states().lock().unwrap(); - let state_arc = active - .iter() - .find(|(s, _)| s == serial) - .map(|(_, state)| Arc::clone(state))?; - drop(active); - state_arc.lock().unwrap().watcher_paused = true; - Some(WatcherGuard { state: state_arc }) -} - -/// Force a rescan of a virtual device's backing directories, identified by -/// serial number. -/// -/// This diffs the in-memory object tree against the actual filesystem and -/// queues `ObjectAdded`/`ObjectRemoved`/`StorageInfoChanged` events for any -/// differences found. -/// -/// Returns `Some(summary)` with the number of added/removed objects, or -/// `None` if no active virtual device with that serial exists. -/// -/// # When to use -/// -/// Call this after manipulating files in the backing directory directly on -/// disk (outside of MTP) when you need the virtual device to reflect those -/// changes immediately. This avoids waiting for the filesystem watcher's -/// latency (200–500ms on macOS). For delete+recreate sequences, pair with -/// [`pause_watcher`] to prevent stale events from corrupting state. -/// -/// # Example -/// -/// ```rust,no_run -/// use mtp_rs::rescan_virtual_device; -/// -/// // After manipulating files in the backing directory... -/// if let Some(summary) = rescan_virtual_device("my-device-serial") { -/// println!("Rescan: {} added, {} removed", summary.added, summary.removed); -/// } -/// ``` -pub fn rescan_virtual_device(serial: &str) -> Option { - let active = active_states().lock().unwrap(); - let state_arc = active - .iter() - .find(|(s, _)| s == serial) - .map(|(_, state)| Arc::clone(state))?; - drop(active); // Release the registry lock before acquiring the state lock. - let mut state = state_arc.lock().unwrap(); - Some(state.rescan_backing_dirs()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::transport::virtual_device::config::VirtualStorageConfig; - use std::time::Duration; - - fn make_config(serial: &str) -> (VirtualDeviceConfig, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Virtual Phone".into(), - serial: serial.into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: dir.path().to_path_buf(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - }; - (config, dir) - } - - #[test] - fn register_and_list() { - let (config, _dir) = make_config("reg-test-001"); - let info = register_virtual_device(&config); - - assert!(info.location_id >= VIRTUAL_LOCATION_BASE); - assert_eq!(info.serial_number.as_deref(), Some("reg-test-001")); - assert_eq!(info.manufacturer.as_deref(), Some("TestCorp")); - - let devices = list_virtual_devices(); - assert!(devices - .iter() - .any(|d| d.serial_number.as_deref() == Some("reg-test-001"))); - - // Clean up - unregister_virtual_device(info.location_id); - } - - #[test] - fn find_by_location() { - let (config, _dir) = make_config("reg-test-002"); - let info = register_virtual_device(&config); - - let found = find_virtual_config_by_location(info.location_id); - assert!(found.is_some()); - assert_eq!(found.unwrap().serial, "reg-test-002"); - - // Clean up - unregister_virtual_device(info.location_id); - } - - #[test] - fn find_by_serial() { - let (config, _dir) = make_config("reg-test-003"); - let info = register_virtual_device(&config); - - let found = find_virtual_config_by_serial("reg-test-003"); - assert!(found.is_some()); - assert_eq!(found.unwrap().serial, "reg-test-003"); - - // Not found - assert!(find_virtual_config_by_serial("nonexistent").is_none()); - - // Clean up - unregister_virtual_device(info.location_id); - } - - #[test] - fn unregister() { - let (config, _dir) = make_config("reg-test-004"); - let info = register_virtual_device(&config); - - unregister_virtual_device(info.location_id); - - assert!(find_virtual_config_by_location(info.location_id).is_none()); - assert!(find_virtual_config_by_serial("reg-test-004").is_none()); - } - - #[test] - fn location_id_unique_after_unregister() { - let (config_a, _dir_a) = make_config("reg-test-unique-a"); - let info_a = register_virtual_device(&config_a); - - let (config_b, _dir_b) = make_config("reg-test-unique-b"); - let info_b = register_virtual_device(&config_b); - - // Unregister A - unregister_virtual_device(info_a.location_id); - - // Register C — must get a unique location_id different from both A and B - let (config_c, _dir_c) = make_config("reg-test-unique-c"); - let info_c = register_virtual_device(&config_c); - - assert_ne!(info_c.location_id, info_a.location_id); - assert_ne!(info_c.location_id, info_b.location_id); - - // Clean up - unregister_virtual_device(info_b.location_id); - unregister_virtual_device(info_c.location_id); - } - - #[tokio::test] - async fn open_by_location_integration() { - let dir = tempfile::tempdir().unwrap(); - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Registry Phone".into(), - serial: "reg-test-005".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: dir.path().to_path_buf(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - }; - let info = register_virtual_device(&config); - - let device = crate::MtpDevice::builder() - .open_by_location(info.location_id) - .await - .unwrap(); - assert_eq!(device.device_info().model, "Registry Phone"); - - // Clean up - unregister_virtual_device(info.location_id); - } - - #[tokio::test] - async fn open_by_serial_integration() { - let dir = tempfile::tempdir().unwrap(); - let config = VirtualDeviceConfig { - manufacturer: "TestCorp".into(), - model: "Registry Phone".into(), - serial: "reg-test-006".into(), - storages: vec![VirtualStorageConfig { - description: "Internal Storage".into(), - capacity: 1024 * 1024 * 1024, - backing_dir: dir.path().to_path_buf(), - read_only: false, - }], - supports_rename: true, - event_poll_interval: Duration::ZERO, - watch_backing_dirs: false, - }; - let info = register_virtual_device(&config); - - let device = crate::MtpDevice::builder() - .open_by_serial("reg-test-006") - .await - .unwrap(); - assert_eq!(device.device_info().model, "Registry Phone"); - - // Clean up - unregister_virtual_device(info.location_id); - } -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/state.rs b/vendor/mtp-rs/src/transport/virtual_device/state.rs deleted file mode 100644 index 38f3190..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/state.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Internal state for the virtual MTP device. - -use super::builders::build_event; -use super::config::{VirtualDeviceConfig, VirtualStorageConfig}; -use crate::ptp::{EventCode, ObjectHandle, StorageId}; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; - -/// Per-storage state. -#[derive(Debug)] -pub(super) struct VirtualStorageState { - pub config: VirtualStorageConfig, - pub storage_id: StorageId, -} - -/// A tracked virtual object (file or directory). -#[derive(Debug, Clone)] -pub(super) struct VirtualObject { - /// Path relative to the storage backing dir. - pub rel_path: PathBuf, - /// Which storage this object belongs to. - pub storage_id: StorageId, - /// Parent handle (`ObjectHandle::ROOT` for root-level objects). - pub parent: ObjectHandle, -} - -/// Pending SendObjectInfo state (MTP requires SendObjectInfo before SendObject). -#[derive(Debug, Clone)] -pub(super) struct PendingSendInfo { - pub storage_id: StorageId, - pub parent: ObjectHandle, - pub filename: String, - #[allow(dead_code)] // Part of the protocol state, kept for debugging - pub size: u64, - pub is_folder: bool, - pub assigned_handle: ObjectHandle, -} - -/// A command waiting for its data phase from the host. -/// -/// When the host sends a command that expects a data phase (SendObjectInfo, -/// SendObject, SetObjectPropValue), the command is stored here. The next -/// `send_bulk` (data container) takes it and dispatches both together. -#[derive(Debug)] -pub(super) struct PendingCommand { - pub code: u16, - pub tx_id: u32, - pub params: Vec, -} - -/// Full mutable state of the virtual device. -#[derive(Debug)] -pub(super) struct VirtualDeviceState { - pub config: VirtualDeviceConfig, - pub session_open: bool, - pub next_handle: u32, - pub objects: HashMap, - pub storages: Vec, - pub pending_send: Option, - pub pending_command: Option, - pub event_queue: VecDeque>, - pub response_queue: VecDeque>, - /// When true, the filesystem watcher silently drops all events. Managed - /// by [`WatcherGuard`](super::registry::WatcherGuard) via - /// [`pause_watcher`](super::registry::pause_watcher). - pub(super) watcher_paused: bool, -} - -impl VirtualDeviceState { - /// Create initial state from config. - pub fn new(config: VirtualDeviceConfig) -> Self { - let storages: Vec = config - .storages - .iter() - .enumerate() - .map(|(i, sc)| { - // Canonicalize backing dirs so that all paths (from handlers, watcher, - // dedup tracker) use the same form. On macOS, /var → /private/var. - let mut resolved_config = sc.clone(); - if let Ok(canonical) = sc.backing_dir.canonicalize() { - resolved_config.backing_dir = canonical; - } - VirtualStorageState { - config: resolved_config, - // Storage IDs conventionally start at 0x00010001 - storage_id: StorageId(0x0001_0001 + i as u32), - } - }) - .collect(); - - Self { - config, - session_open: false, - next_handle: 1, - objects: HashMap::new(), - storages, - pending_send: None, - pending_command: None, - event_queue: VecDeque::new(), - response_queue: VecDeque::new(), - watcher_paused: false, - } - } - - /// Allocate the next object handle. - pub fn alloc_handle(&mut self) -> ObjectHandle { - let h = self.next_handle; - self.next_handle += 1; - ObjectHandle(h) - } - - /// Find a storage state by ID. - pub fn find_storage(&self, id: StorageId) -> Option<&VirtualStorageState> { - self.storages.iter().find(|s| s.storage_id == id) - } - - /// Find or create handles for all entries in a directory. - /// Returns handles for direct children of `parent` within the given storage. - pub fn scan_dir( - &mut self, - storage_id: StorageId, - parent: ObjectHandle, - ) -> Result, std::io::Error> { - let storage = self - .storages - .iter() - .find(|s| s.storage_id == storage_id) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "storage not found") - })?; - - let base_dir = &storage.config.backing_dir; - - // Determine the filesystem path for this parent - let dir_path = if parent == ObjectHandle::ROOT || parent.0 == 0 { - base_dir.clone() - } else { - match self.objects.get(&parent.0) { - Some(obj) if obj.storage_id == storage_id => base_dir.join(&obj.rel_path), - _ => return Ok(Vec::new()), - } - }; - - if !dir_path.is_dir() { - return Ok(Vec::new()); - } - - let mut handles = Vec::new(); - let entries = std::fs::read_dir(&dir_path)?; - - for entry in entries { - let entry = entry?; - let file_name = entry.file_name(); - let rel = if parent == ObjectHandle::ROOT || parent.0 == 0 { - PathBuf::from(&file_name) - } else { - let parent_obj = self.objects.get(&parent.0).unwrap(); - parent_obj.rel_path.join(&file_name) - }; - - // Check if we already have a handle for this path in this storage - let existing = self - .objects - .iter() - .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel); - - let handle = if let Some((&h, _)) = existing { - ObjectHandle(h) - } else { - let h = self.alloc_handle(); - self.objects.insert( - h.0, - VirtualObject { - rel_path: rel, - storage_id, - parent, - }, - ); - h - }; - - handles.push(handle); - } - - Ok(handles) - } - - /// Recursively scan all objects in a storage. - pub fn scan_all(&mut self, storage_id: StorageId) -> Result, std::io::Error> { - // First scan root - let root_handles = self.scan_dir(storage_id, ObjectHandle::ROOT)?; - let mut all_handles = root_handles.clone(); - - // BFS through directories - let mut queue = root_handles; - while let Some(handle) = queue.pop() { - let obj = match self.objects.get(&handle.0) { - Some(o) => o.clone(), - None => continue, - }; - let storage = match self.find_storage(storage_id) { - Some(s) => s, - None => continue, - }; - let full_path = storage.config.backing_dir.join(&obj.rel_path); - if full_path.is_dir() { - let children = self.scan_dir(storage_id, handle)?; - all_handles.extend(&children); - queue.extend(children); - } - } - - Ok(all_handles) - } - - /// Resolve the full filesystem path for an object handle. - pub fn resolve_path(&self, handle: ObjectHandle) -> Option { - let obj = self.objects.get(&handle.0)?; - let storage = self.find_storage(obj.storage_id)?; - Some(storage.config.backing_dir.join(&obj.rel_path)) - } - - /// Check if a storage is read-only. - pub fn is_read_only(&self, storage_id: StorageId) -> bool { - self.find_storage(storage_id) - .map(|s| s.config.read_only) - .unwrap_or(false) - } - - /// Force a full rescan of all backing directories, syncing the in-memory - /// object tree with the actual filesystem state. - /// - /// This diffs the current `objects` map against the filesystem: - /// - **Stale entries** (objects whose `rel_path` no longer exists on disk) are removed. - /// - **New entries** (files on disk not tracked in `objects`) are added. - /// - /// Appropriate `ObjectAdded`, `ObjectRemoved`, and `StorageInfoChanged` - /// events are queued for each change so MTP clients can pick them up - /// via `receive_interrupt()`. - /// - /// Use this when you've manipulated the backing directories directly - /// (for example, resetting test fixtures) and need the virtual device to - /// reflect the changes immediately, without waiting for the filesystem - /// watcher's latency. - pub fn rescan_backing_dirs(&mut self) -> RescanSummary { - let mut added = 0u32; - let mut removed = 0u32; - let mut changed_storages = HashSet::new(); - - // Phase 1: Remove stale entries (objects whose files no longer exist on disk). - let stale_handles: Vec<(u32, StorageId)> = self - .objects - .iter() - .filter_map(|(&handle, obj)| { - let storage = self - .storages - .iter() - .find(|s| s.storage_id == obj.storage_id)?; - let full_path = storage.config.backing_dir.join(&obj.rel_path); - if !full_path.exists() { - Some((handle, obj.storage_id)) - } else { - None - } - }) - .collect(); - - for (handle, storage_id) in stale_handles { - self.objects.remove(&handle); - self.event_queue - .push_back(build_event(EventCode::ObjectRemoved, &[handle])); - changed_storages.insert(storage_id); - removed += 1; - } - - // Phase 2: Add new entries by scanning all storages recursively. - // Collect storage info upfront to avoid borrow conflicts. - let storage_info: Vec<(StorageId, PathBuf)> = self - .storages - .iter() - .map(|s| (s.storage_id, s.config.backing_dir.clone())) - .collect(); - - for (storage_id, backing_dir) in &storage_info { - let storage_added = - self.rescan_dir(*storage_id, backing_dir, backing_dir, ObjectHandle::ROOT); - added += storage_added; - if storage_added > 0 { - changed_storages.insert(*storage_id); - } - } - - // Phase 3: Queue StorageInfoChanged for each affected storage. - for storage_id in &changed_storages { - self.event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); - } - - RescanSummary { added, removed } - } - - /// Recursively scan a directory and add any entries not already tracked. - /// Returns the number of newly added objects. - fn rescan_dir( - &mut self, - storage_id: StorageId, - backing_dir: &std::path::Path, - dir_path: &std::path::Path, - parent: ObjectHandle, - ) -> u32 { - let entries = match std::fs::read_dir(dir_path) { - Ok(e) => e, - Err(_) => return 0, - }; - - let mut added = 0u32; - - for entry in entries.flatten() { - let full_path = entry.path(); - let rel_path = match full_path.strip_prefix(backing_dir) { - Ok(r) => r.to_path_buf(), - Err(_) => continue, - }; - - // Check if already tracked. - let existing = self - .objects - .iter() - .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path) - .map(|(&h, _)| ObjectHandle(h)); - - let handle = if let Some(h) = existing { - h - } else { - let h = self.alloc_handle(); - self.objects.insert( - h.0, - VirtualObject { - rel_path: rel_path.clone(), - storage_id, - parent, - }, - ); - self.event_queue - .push_back(build_event(EventCode::ObjectAdded, &[h.0])); - added += 1; - h - }; - - // Recurse into directories. - if full_path.is_dir() { - added += self.rescan_dir(storage_id, backing_dir, &full_path, handle); - } - } - - added - } -} - -/// Summary of changes made by a rescan. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RescanSummary { - /// Number of new objects added to the object tree. - pub added: u32, - /// Number of stale objects removed from the object tree. - pub removed: u32, -} diff --git a/vendor/mtp-rs/src/transport/virtual_device/watcher.rs b/vendor/mtp-rs/src/transport/virtual_device/watcher.rs deleted file mode 100644 index c164612..0000000 --- a/vendor/mtp-rs/src/transport/virtual_device/watcher.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Filesystem watcher for detecting out-of-band changes to backing directories. -//! -//! When files are written directly to a virtual device's backing directory (bypassing -//! MTP protocol operations), this watcher detects the changes and queues the -//! corresponding MTP events (`ObjectAdded`, `ObjectRemoved`). -//! -//! ## Dedup strategy -//! -//! MTP protocol handlers (upload, delete, move, etc.) modify the filesystem while -//! holding the `state` mutex and insert object handles into `state.objects` before -//! releasing the lock. The watcher callback also acquires `state` before processing -//! events. This mutex serialization guarantees that by the time the watcher sees a -//! filesystem notification for an MTP-initiated change, the state already reflects it: -//! -//! - **Creates**: the handle already exists in `state.objects` → watcher skips the event. -//! - **Removes**: the handle has already been removed from `state.objects` → watcher -//! finds no handle for the path → nothing to emit. -//! -//! No TTL, no extra tracking structure — the state itself is the dedup mechanism. -//! -//! ## Pause/resume -//! -//! The watcher can be paused via [`pause_watcher`](super::registry::pause_watcher), -//! which returns a [`WatcherGuard`](super::registry::WatcherGuard) that resumes -//! on drop. While paused, all filesystem events are silently dropped. This is -//! needed when external code deletes and recreates files in the backing directory: -//! without pausing, the watcher may process stale deletion events after a rescan -//! has already added the new objects, incorrectly removing them. - -use super::builders::build_event; -use super::state::VirtualDeviceState; -use crate::ptp::{EventCode, ObjectHandle, StorageId}; -use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::path::PathBuf; -use std::sync::Mutex; - -/// Mapping from backing dir to its storage ID, used by the watcher callback. -type StorageMap = Vec<(PathBuf, StorageId)>; - -/// Start a filesystem watcher for all backing directories. -/// -/// Returns the watcher (must be kept alive) or `None` if starting fails. -/// The watcher pushes events into `state.event_queue` via the shared mutex. -pub(super) fn start_fs_watcher( - state: &std::sync::Arc>, -) -> Option { - let state_clone = std::sync::Arc::clone(state); - - // Build a map of backing_dir → storage_id for resolving events. - let storage_map: StorageMap = { - let s = state.lock().unwrap(); - s.storages - .iter() - .map(|ss| { - let dir = ss - .config - .backing_dir - .canonicalize() - .unwrap_or_else(|_| ss.config.backing_dir.clone()); - (dir, ss.storage_id) - }) - .collect() - }; - - let mut watcher = RecommendedWatcher::new( - move |res: Result| { - let event = match res { - Ok(e) => e, - Err(_) => return, - }; - - handle_notify_event(&state_clone, &storage_map, event); - }, - Config::default(), - ) - .ok()?; - - // Watch all backing directories recursively. - let state_lock = state.lock().unwrap(); - for storage in &state_lock.storages { - let _ = watcher.watch(&storage.config.backing_dir, RecursiveMode::Recursive); - } - drop(state_lock); - - Some(watcher) -} - -/// Process a single notify event and queue MTP events if appropriate. -fn handle_notify_event( - state: &std::sync::Arc>, - storage_map: &StorageMap, - event: notify::Event, -) { - // Only handle create, remove, and rename events. - let is_create = matches!( - event.kind, - EventKind::Create(_) - | EventKind::Modify(notify::event::ModifyKind::Name( - notify::event::RenameMode::To - )) - ); - let is_remove = matches!( - event.kind, - EventKind::Remove(_) - | EventKind::Modify(notify::event::ModifyKind::Name( - notify::event::RenameMode::From - )) - ); - // macOS FSEvents can't distinguish rename source from target, so it reports - // RenameMode::Any. We determine the direction by checking if the path exists. - let is_rename_any = matches!( - event.kind, - EventKind::Modify(notify::event::ModifyKind::Name( - notify::event::RenameMode::Any - )) - ); - - if !is_create && !is_remove && !is_rename_any { - return; - } - - for path in &event.paths { - // Canonicalize for reliable comparison (ignore errors for removed paths). - let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); - - // For RenameMode::Any, determine direction from whether the path exists. - let (is_create, _is_remove) = if is_rename_any { - if path.exists() { - (true, false) - } else { - (false, true) - } - } else { - (is_create, is_remove) - }; - - // Find which storage this path belongs to. - let (storage_id, backing_dir) = match find_storage_for_path(&canonical, storage_map) { - Some(v) => v, - None => continue, - }; - - // Compute relative path within the storage. - let rel_path = match canonical.strip_prefix(backing_dir) { - Ok(r) => r.to_path_buf(), - Err(_) => continue, - }; - - // Skip events for the backing directory itself (empty rel_path). - // macOS FSEvents reports the watched directory as "created" on startup. - if rel_path.as_os_str().is_empty() { - continue; - } - - let mut state = state.lock().unwrap(); - - if state.watcher_paused { - continue; - } - - if is_create { - // Check if a handle already exists for this path. If so, the MTP handler - // already created it and emitted events — skip to avoid duplicates. - let already_known = state - .objects - .iter() - .any(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path); - - if already_known { - continue; - } - - // Determine the parent handle. - let parent = - if let Some(parent_rel) = rel_path.parent() { - if parent_rel == std::path::Path::new("") { - ObjectHandle::ROOT - } else { - // Look up the parent object. - match state.objects.iter().find(|(_, obj)| { - obj.storage_id == storage_id && obj.rel_path == parent_rel - }) { - Some((&h, _)) => ObjectHandle(h), - None => ObjectHandle::ROOT, - } - } - } else { - ObjectHandle::ROOT - }; - - let handle = state.alloc_handle(); - state.objects.insert( - handle.0, - super::state::VirtualObject { - rel_path, - storage_id, - parent, - }, - ); - - state - .event_queue - .push_back(build_event(EventCode::ObjectAdded, &[handle.0])); - state - .event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); - } else { - // Remove: find the handle for this path and remove it. - // If the handle is already gone, the MTP handler already processed - // the removal — skip to avoid duplicates. - let handle = state - .objects - .iter() - .find(|(_, obj)| obj.storage_id == storage_id && obj.rel_path == rel_path) - .map(|(&h, _)| h); - - if let Some(h) = handle { - state.objects.remove(&h); - state - .event_queue - .push_back(build_event(EventCode::ObjectRemoved, &[h])); - state - .event_queue - .push_back(build_event(EventCode::StorageInfoChanged, &[storage_id.0])); - } - } - } -} - -/// Find which storage a path belongs to by checking if it's under a backing dir. -fn find_storage_for_path<'a>( - path: &std::path::Path, - storage_map: &'a StorageMap, -) -> Option<(StorageId, &'a PathBuf)> { - storage_map - .iter() - .find(|(dir, _)| path.starts_with(dir)) - .map(|(dir, sid)| (*sid, dir)) -} From 9d33e628883d60f147977ff934949ad8aad5e442 Mon Sep 17 00:00:00 2001 From: luke miles Date: Fri, 28 Aug 2026 09:06:07 -0400 Subject: [PATCH 3/3] fix: preserve TP-7 folder error handling --- src/write_ops.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/write_ops.rs b/src/write_ops.rs index f89f98d..cf05067 100644 --- a/src/write_ops.rs +++ b/src/write_ops.rs @@ -269,9 +269,11 @@ fn renamed_path(old_path: &str, new_name: &str) -> String { fn map_create_folder_error(error: mtp_rs::Error) -> AppError { match error { - mtp_rs::Error::Unsupported | mtp_rs::Error::AccessDenied => AppError::MtpUnsupported { - message: format!("TP-7 rejected folder creation ({error})"), - }, + mtp_rs::Error::Unsupported | mtp_rs::Error::AccessDenied | mtp_rs::Error::Other { .. } => { + AppError::MtpUnsupported { + message: format!("TP-7 rejected folder creation ({error})"), + } + } _ => map_mtp_error(error), } } @@ -286,6 +288,18 @@ mod tests { assert!(validate_remote_name("").is_err()); } + #[test] + fn maps_tp7_general_folder_error_to_unsupported() { + let error = mtp_rs::Error::Other { + detail: "GeneralError".to_string(), + }; + + assert!(matches!( + map_create_folder_error(error), + AppError::MtpUnsupported { .. } + )); + } + #[test] fn builds_renamed_root_child_path() { assert_eq!(renamed_path("/old.txt", "new.txt"), "/new.txt");