293 lines
9.8 KiB
Rust
293 lines
9.8 KiB
Rust
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<gio::File> {
|
|
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<gio::File> {
|
|
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<F>(
|
|
profile: &LocationProfile,
|
|
directory_uri: &str,
|
|
operation: >k::MountOperation,
|
|
mut progress: F,
|
|
) -> Result<Vec<BrowserEntry>>
|
|
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::Window>,
|
|
) -> 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<gio::FileEnumerator, glib::Error> {
|
|
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<std::time::SystemTime> {
|
|
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));
|
|
}
|
|
}
|