use std::path::Path; use std::time::{Duration, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use gtk::gio; use gtk::gio::prelude::*; use gtk::glib; use percent_encoding::percent_decode_str; use crate::browser::{BrowserEntry, EntryKind, classify_file}; use crate::config::{LocationKind, LocationProfile}; const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value"; pub fn root_file(profile: &LocationProfile) -> Result { validate_profile(profile)?; Ok(file_from_user_input(profile.kind, &profile.address)) } pub fn file_from_user_input(kind: LocationKind, input: &str) -> gio::File { match kind { LocationKind::Local => gio::File::for_path(input), _ => gio::File::for_uri(input), } } pub fn display_remote_uri(uri: &str) -> String { percent_decode_str(uri).decode_utf8_lossy().into_owned() } pub fn is_within_root(file: &gio::File, root: &gio::File) -> bool { if file.equal(root) || file.has_prefix(root) { return true; } let mut root_uri = root.uri().to_string(); if !root_uri.ends_with('/') { root_uri.push('/'); } file.uri().starts_with(&root_uri) } pub fn parent_within_root(current: &gio::File, root: &gio::File) -> Option { if current.equal(root) { return None; } if let Some(parent) = current.parent() && is_within_root(&parent, root) { return Some(parent); } is_within_root(current, root).then(|| root.clone()) } pub fn validate_profile(profile: &LocationProfile) -> Result<()> { if profile.name.trim().is_empty() || profile.address.trim().is_empty() { bail!("위치 이름과 주소를 입력해 주세요."); } match profile.kind { LocationKind::Local if !Path::new(&profile.address).is_absolute() => { bail!("로컬 위치는 절대 경로여야 합니다.") } LocationKind::Smb if !profile.address.starts_with("smb://") => { bail!("SMB 주소는 smb://로 시작해야 합니다.") } LocationKind::Ftp if !profile.address.starts_with("ftp://") => { bail!("FTP 주소는 ftp://로 시작해야 합니다.") } LocationKind::Sftp if !profile.address.starts_with("sftp://") => { bail!("SFTP 주소는 sftp://로 시작해야 합니다.") } _ => {} } Ok(()) } pub async fn enumerate_directory( profile: &LocationProfile, directory_uri: &str, operation: >k::MountOperation, mut progress: F, ) -> Result> where F: FnMut(usize), { let directory = gio::File::for_uri(directory_uri); let enumerator = match enumerate_with_mount_retry( &directory, operation, profile.kind != LocationKind::Local, ) .await { Ok(enumerator) => enumerator, Err(error) if error.matches(gio::IOErrorEnum::NotSupported) => { bail!("원격 파일 시스템 백엔드가 없습니다. gvfs와 gvfs-smb를 설치해 주세요: {error}") } Err(error) => { return Err(error) .with_context(|| format!("폴더에 접근할 수 없습니다: {directory_uri}")); } }; let mut entries = Vec::new(); let mut scanned = 0; loop { let batch = enumerator .next_files_future(128, glib::Priority::DEFAULT) .await?; if batch.is_empty() { break; } scanned += batch.len(); progress(scanned); for info in batch { let kind = match info.file_type() { gio::FileType::Directory => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), _ => continue, }; let child = enumerator.child(&info); let uri = info .attribute_string("standard::target-uri") .map_or_else(|| child.uri().into(), Into::into); entries.push(BrowserEntry { uri, name: info.display_name().into(), kind, size: info .has_attribute("standard::size") .then(|| info.size()) .filter(|size| *size >= 0) .map(|size| size as u64), modified: attribute_time(&info, "time::modified"), created: attribute_time(&info, "time::created"), etag: info .has_attribute("etag::value") .then(|| info.etag()) .flatten() .map(Into::into), }); } } Ok(entries) } pub fn mount_operation( profile: &LocationProfile, parent: &impl glib::object::IsA, ) -> gtk::MountOperation { let operation = gtk::MountOperation::new(Some(parent)); let username = profile.username.clone(); let password = profile.password.clone(); let domain = profile.domain.clone(); operation.connect_ask_password(move |operation, _, _, _, flags| { let missing_username = flags.contains(gio::AskPasswordFlags::NEED_USERNAME) && username.is_empty(); let missing_password = flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) && password.is_empty(); if missing_username || missing_password { return; } if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) { operation.set_username(Some(&username)); } if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) { operation.set_password(Some(&password)); } if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) { operation.set_domain(Some(&domain)); } operation.set_anonymous(false); operation.set_password_save(gio::PasswordSave::Never); operation.stop_signal_emission_by_name("ask-password"); operation.reply(gio::MountOperationResult::Handled); }); operation } pub async fn ensure_mounted( file: &gio::File, profile: &LocationProfile, operation: >k::MountOperation, ) -> Result<()> { if profile.kind == LocationKind::Local || file.is_native() { return Ok(()); } match file .mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation)) .await { Ok(()) => Ok(()), Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => Ok(()), Err(error) => { Err(error).with_context(|| format!("원격 위치를 마운트할 수 없습니다: {}", file.uri())) } } } async fn enumerate_with_mount_retry( directory: &gio::File, operation: >k::MountOperation, allow_mount: bool, ) -> Result { let first = directory .enumerate_children_future( ATTRIBUTES, gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT, ) .await; let Err(error) = first else { return first; }; if !allow_mount || !error.matches(gio::IOErrorEnum::NotMounted) { return Err(error); } match directory .mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation)) .await { Ok(()) => {} Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => {} Err(error) => return Err(error), } directory .enumerate_children_future( ATTRIBUTES, gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT, ) .await } fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option { info.has_attribute(attribute) .then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute))) } #[cfg(test)] mod tests { use super::*; #[test] fn validates_protocol_schemes() { let profile = LocationProfile { name: "서버".into(), kind: LocationKind::Sftp, address: "ftp://example.invalid/comics".into(), username: String::new(), password: String::new(), domain: String::new(), }; assert!(validate_profile(&profile).is_err()); } #[test] fn smb_paths_stay_inside_saved_root() { let root = gio::File::for_uri("smb://example.invalid/ComicsA"); let child = gio::File::for_uri("smb://example.invalid/ComicsA/favorites"); let sibling = gio::File::for_uri("smb://example.invalid/ComicsB"); assert!(is_within_root(&child, &root)); assert!(!is_within_root(&sibling, &root)); assert!(parent_within_root(&child, &root).is_some_and(|parent| parent.equal(&root))); assert!(parent_within_root(&root, &root).is_none()); } #[test] fn mounted_share_can_return_to_smb_server_root() { let root = gio::File::for_uri("smb://example.invalid"); let share = gio::File::for_uri("smb://example.invalid/ComicsA"); assert!(is_within_root(&share, &root)); assert!(parent_within_root(&share, &root).is_some_and(|parent| parent.equal(&root))); } #[test] fn ftp_parse_name_round_trips_utf8_paths() { let encoded = gio::File::for_uri("ftp://example.invalid/%ED%95%9C%EA%B8%80%20%ED%8F%B4%EB%8D%94"); let display_name = display_remote_uri(encoded.uri().as_str()); assert!(display_name.contains("한글 폴더")); let decoded = file_from_user_input(LocationKind::Ftp, &display_name); assert!(decoded.equal(&encoded)); let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str()); assert!(encoded_input.equal(&encoded)); } }