Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "archive-vfs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
bitflags = "2"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# 네트워크 백엔드 — 전부 순수 Rust + rustls/ring 조합으로 C 툴체인 리스크 제거.
|
||||
russh = { version = "0.62", default-features = false, features = ["ring", "flate2", "rsa"] }
|
||||
russh-sftp = "2"
|
||||
suppaftp = { version = "10", features = ["tokio-rustls-ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
reqwest_dav = "0.3"
|
||||
futures-util = "0.3"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# 자격증명: OS 키체인(우선) + 포터블 볼트(ChaCha20-Poly1305 + Argon2id)
|
||||
keyring = { version = "3.6", default-features = false }
|
||||
chacha20poly1305 = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.9"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3.6", default-features = false, features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 개발/테스트용 최소 WebDAV 서버 — 실제 디렉터리를 서빙한다.
|
||||
//! 사용: cargo run -p archive-vfs --example webdav_server -- <포트> <디렉터리>
|
||||
//! PROPFIND(Depth 0/1) + GET(Range) + MOVE + DELETE 지원. 인증 없음(로컬 전용).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8080);
|
||||
let root = PathBuf::from(args.get(2).cloned().unwrap_or_else(|| ".".into()));
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
println!("WebDAV 서버: http://127.0.0.1:{port}/ root={}", root.display());
|
||||
loop {
|
||||
let (sock, _) = listener.accept().await.unwrap();
|
||||
let root = root.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(sock, root).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(mut sock: TcpStream, root: PathBuf) -> std::io::Result<()> {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
let n = sock.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let mut lines = req.lines();
|
||||
let first = lines.next().unwrap_or("");
|
||||
let mut parts = first.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let raw_path = parts.next().unwrap_or("/");
|
||||
let path = urldecode(raw_path);
|
||||
let depth = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("depth:"))
|
||||
.and_then(|l| l.split(':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "1".into());
|
||||
|
||||
let fs_path = root.join(path.trim_start_matches('/'));
|
||||
|
||||
match method {
|
||||
"OPTIONS" => {
|
||||
write_head(&mut sock, "200 OK", "text/plain", 0, None, "DAV: 1\r\nAllow: OPTIONS, GET, PROPFIND, MOVE, DELETE, MKCOL, PUT\r\n").await?;
|
||||
}
|
||||
"PROPFIND" => {
|
||||
let xml = build_propfind(&root, &fs_path, &path, &depth);
|
||||
write_head(&mut sock, "207 Multi-Status", "application/xml; charset=utf-8", xml.len(), None, "").await?;
|
||||
sock.write_all(xml.as_bytes()).await?;
|
||||
}
|
||||
"GET" => {
|
||||
if let Ok(data) = tokio::fs::read(&fs_path).await {
|
||||
let range = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("range:"))
|
||||
.and_then(|l| l.split("bytes=").nth(1))
|
||||
.map(|s| s.trim().to_string());
|
||||
match range {
|
||||
Some(spec) => {
|
||||
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
|
||||
let start: usize = s.trim().parse().unwrap_or(0);
|
||||
let end: usize = e.trim().parse().unwrap_or(data.len().saturating_sub(1));
|
||||
let end = end.min(data.len().saturating_sub(1));
|
||||
let slice = &data[start..=end];
|
||||
let cr = format!("bytes {}-{}/{}", start, end, data.len());
|
||||
write_head(&mut sock, "206 Partial Content", "application/octet-stream", slice.len(), Some(&cr), "").await?;
|
||||
sock.write_all(slice).await?;
|
||||
}
|
||||
None => {
|
||||
write_head(&mut sock, "200 OK", "application/octet-stream", data.len(), None, "").await?;
|
||||
sock.write_all(&data).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write_head(&mut sock, "404 Not Found", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
"MOVE" => {
|
||||
let dest = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("destination:"))
|
||||
.and_then(|l| l.splitn(2, ':').nth(1))
|
||||
.map(|s| urldecode(s.trim()))
|
||||
.unwrap_or_default();
|
||||
// Destination은 전체 URL — 경로 부분만 추출
|
||||
let dest_path = dest.split("//").nth(1).and_then(|s| s.split_once('/')).map(|(_, p)| format!("/{p}")).unwrap_or(dest);
|
||||
let dfs = root.join(dest_path.trim_start_matches('/'));
|
||||
let _ = tokio::fs::rename(&fs_path, &dfs).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"DELETE" => {
|
||||
let _ = tokio::fs::remove_file(&fs_path).await;
|
||||
write_head(&mut sock, "204 No Content", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"MKCOL" => {
|
||||
let _ = tokio::fs::create_dir_all(&fs_path).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
_ => {
|
||||
write_head(&mut sock, "405 Method Not Allowed", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
sock.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_head(
|
||||
sock: &mut TcpStream,
|
||||
status: &str,
|
||||
ctype: &str,
|
||||
len: usize,
|
||||
content_range: Option<&str>,
|
||||
extra: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len}\r\nAccept-Ranges: bytes\r\n"
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str(extra);
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
sock.write_all(head.as_bytes()).await
|
||||
}
|
||||
|
||||
fn build_propfind(root: &Path, fs_path: &Path, url_path: &str, depth: &str) -> String {
|
||||
let mut responses = String::new();
|
||||
let base = url_path.trim_end_matches('/');
|
||||
|
||||
// 요청 대상 자신
|
||||
if fs_path.is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{base}/")));
|
||||
if depth != "0" {
|
||||
if let Ok(rd) = std::fs::read_dir(fs_path) {
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let child_url = format!("{base}/{}", urlencode(&name));
|
||||
if entry.path().is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{child_url}/")));
|
||||
} else {
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(&child_url, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size = fs_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(base, size));
|
||||
}
|
||||
let _ = root;
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">{responses}</D:multistatus>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_folder(href: &str) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_file(href: &str, size: u64) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>{size}</D:getcontentlength><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 자격증명 저장 — 기본은 OS 키체인, 포터블 모드는 암호화 볼트(opt-in).
|
||||
//! SQLite에는 비밀번호를 절대 저장하지 않는다 (계획서 §9).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SERVICE: &str = "kr.co.elio.archive";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CredError {
|
||||
#[error("키체인 오류: {0}")]
|
||||
Keyring(String),
|
||||
#[error("볼트 오류: {0}")]
|
||||
Vault(String),
|
||||
#[error("잠긴 볼트 — 패스프레이즈 필요")]
|
||||
Locked,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CredError>;
|
||||
|
||||
/// 자격증명 백엔드.
|
||||
pub enum CredStore {
|
||||
/// OS 키체인 (Windows Credential Manager / macOS Keychain)
|
||||
Keychain,
|
||||
/// 포터블 암호화 볼트 (exe 옆 vault.bin)
|
||||
Vault(Vault),
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
entry.set_password(secret).map_err(|e| CredError::Keyring(e.to_string()))
|
||||
}
|
||||
CredStore::Vault(v) => v.set(key, secret),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.get(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.delete(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ChaCha20-Poly1305 + Argon2id 암호화 볼트 (포터블 모드).
|
||||
pub struct Vault {
|
||||
path: PathBuf,
|
||||
key: [u8; 32],
|
||||
data: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// 패스프레이즈로 볼트를 열거나(기존) 새로 만든다.
|
||||
pub fn open(path: PathBuf, passphrase: &str) -> Result<Vault> {
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
|
||||
// 볼트 파일의 salt를 재사용하거나 새로 생성
|
||||
let (salt_b64, ciphertext) = if path.exists() {
|
||||
let bytes = std::fs::read(&path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let (salt, ct) = content.split_once('\n').ok_or_else(|| CredError::Vault("형식 오류".into()))?;
|
||||
(salt.to_string(), Some(ct.to_string()))
|
||||
} else {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
(salt.as_str().to_string(), None)
|
||||
};
|
||||
|
||||
let salt = SaltString::from_b64(&salt_b64).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(passphrase.as_bytes(), &salt)
|
||||
.map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash_bytes = hash.hash.ok_or_else(|| CredError::Vault("해시 없음".into()))?;
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
|
||||
|
||||
let data = match ciphertext {
|
||||
Some(ct) if !ct.is_empty() => decrypt_map(&key, &ct)?,
|
||||
_ => std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let vault = Vault {
|
||||
path,
|
||||
key,
|
||||
data: std::sync::Mutex::new(data),
|
||||
};
|
||||
// salt를 즉시 기록 (새 볼트)
|
||||
vault.persist(&salt_b64)?;
|
||||
Ok(vault)
|
||||
}
|
||||
|
||||
fn persist(&self, salt_b64: &str) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let ct = encrypt_map(&self.key, &data)?;
|
||||
std::fs::write(&self.path, format!("{salt_b64}\n{ct}")).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
fn salt_from_file(&self) -> Result<String> {
|
||||
let bytes = std::fs::read(&self.path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
Ok(content.split_once('\n').map(|(s, _)| s.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().insert(key.to_string(), secret.to_string());
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.data.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().remove(key);
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_map(key: &[u8; 32], map: &std::collections::HashMap<String, String>) -> Result<String> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let plaintext = serde_json::to_vec(map).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher.encrypt(nonce, plaintext.as_ref()).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ct);
|
||||
Ok(base64_encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_map(key: &[u8; 32], b64: &str) -> Result<std::collections::HashMap<String, String>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let combined = base64_decode(b64).map_err(|e| CredError::Vault(e))?;
|
||||
if combined.len() < 12 {
|
||||
return Err(CredError::Vault("암호문 길이 오류".into()));
|
||||
}
|
||||
let (nonce_bytes, ct) = combined.split_at(12);
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ct)
|
||||
.map_err(|_| CredError::Vault("복호화 실패 (패스프레이즈 불일치?)".into()))?;
|
||||
serde_json::from_slice(&plaintext).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
// 표준 base64 (URL-safe 아님) — 외부 크레이트 없이 구현
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
|
||||
out.push(T[((n >> 18) & 63) as usize] as char);
|
||||
out.push(T[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
fn val(c: u8) -> std::result::Result<u32, String> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Ok((c - b'A') as u32),
|
||||
b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
|
||||
b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
|
||||
b'+' => Ok(62),
|
||||
b'/' => Ok(63),
|
||||
_ => Err("잘못된 base64 문자".into()),
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = s.bytes().filter(|c| *c != b'=' && !c.is_ascii_whitespace()).collect();
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for chunk in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
let mut bits = 0;
|
||||
for &c in chunk {
|
||||
n = (n << 6) | val(c)?;
|
||||
bits += 6;
|
||||
}
|
||||
n <<= 24 - bits;
|
||||
out.push((n >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip() {
|
||||
for input in [&b""[..], b"a", b"ab", b"abc", b"abcd", b"hello world!"] {
|
||||
let enc = base64_encode(input);
|
||||
assert_eq!(base64_decode(&enc).unwrap(), input, "input={input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_encrypt_persist_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("vault.bin");
|
||||
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
v.set("sftp:1:password", "s3cr3t비밀").unwrap();
|
||||
v.set("ftp:2:password", "another").unwrap();
|
||||
}
|
||||
// 재오픈 (같은 패스프레이즈)
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
assert_eq!(v.get("sftp:1:password").unwrap().as_deref(), Some("s3cr3t비밀"));
|
||||
assert_eq!(v.get("ftp:2:password").unwrap().as_deref(), Some("another"));
|
||||
v.delete("ftp:2:password").unwrap();
|
||||
assert_eq!(v.get("ftp:2:password").unwrap(), None);
|
||||
}
|
||||
// 잘못된 패스프레이즈 → 복호화 실패
|
||||
{
|
||||
let result = Vault::open(path.clone(), "wrong-passphrase");
|
||||
assert!(result.is_err(), "잘못된 패스프레이즈는 실패해야 함");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! FTP/FTPS 백엔드 (suppaftp, tokio + rustls).
|
||||
//! FTP는 데이터 커넥션당 전송 하나라 Range는 REST 오프셋으로 구현한다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct FtpFs {
|
||||
stream: Arc<Mutex<AsyncFtpStream>>,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl FtpFs {
|
||||
pub async fn connect(cfg: &FtpConfig) -> Result<FtpFs> {
|
||||
let mut stream = AsyncFtpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 연결: {e}")))?;
|
||||
stream
|
||||
.login(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 로그인: {e}")))?;
|
||||
// 바이너리 모드
|
||||
let _ = stream.transfer_type(suppaftp::types::FileType::Binary).await;
|
||||
Ok(FtpFs {
|
||||
stream: Arc::new(Mutex::new(stream)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for FtpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
// FTP는 병렬 읽기·서버측 rename 지원, 하지만 Range는 REST 재연결 기반
|
||||
Caps::SERVER_MOVE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let list = stream
|
||||
.list(Some(&full))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("LIST: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for line in list {
|
||||
if let Some(entry) = parse_list_line(&line) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let size = stream.size(&full).await.unwrap_or(0) as u64;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: size == 0,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
if let Some(r) = &range {
|
||||
if r.start > 0 {
|
||||
stream
|
||||
.resume_transfer(r.start as usize)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("REST: {e}")))?;
|
||||
}
|
||||
}
|
||||
let mut data_stream = stream
|
||||
.retr_as_stream(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("RETR: {e}")))?;
|
||||
// Range 길이만큼(또는 전체) 메모리로 읽는다
|
||||
let mut buf = Vec::new();
|
||||
match &range {
|
||||
Some(r) => {
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut limited = (&mut data_stream).take(len);
|
||||
limited.read_to_end(&mut buf).await?;
|
||||
}
|
||||
None => {
|
||||
data_stream.read_to_end(&mut buf).await?;
|
||||
}
|
||||
}
|
||||
// 데이터 커넥션 정리
|
||||
let _ = stream.finalize_retr_stream(data_stream).await;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rename(&f, &t).await.map_err(|e| VfsError::Protocol(format!("RNFR/RNTO: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rm(&full).await.map_err(|e| VfsError::Protocol(format!("DELE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let _ = stream.mkdir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// UNIX 스타일 LIST 라인 파싱 (권한 파싱 최소화 — 디렉터리 여부 + 이름 + 크기)
|
||||
fn parse_list_line(line: &str) -> Option<VEntry> {
|
||||
// 예: "drwxr-xr-x 2 user group 4096 Jan 01 12:00 폴더명"
|
||||
let parts: Vec<&str> = line.splitn(9, char::is_whitespace).filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let perms = parts[0];
|
||||
let is_dir = perms.starts_with('d');
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
let name = parts[8].to_string();
|
||||
if name == "." || name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(VEntry {
|
||||
name,
|
||||
is_dir,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_unix_list() {
|
||||
let dir = parse_list_line("drwxr-xr-x 2 user group 4096 Jan 01 12:00 여행폴더").unwrap();
|
||||
assert!(dir.is_dir);
|
||||
assert_eq!(dir.name, "여행폴더");
|
||||
|
||||
let file = parse_list_line("-rw-r--r-- 1 user group 102400 Feb 15 09:30 사진.jpg").unwrap();
|
||||
assert!(!file.is_dir);
|
||||
assert_eq!(file.size, 102400);
|
||||
assert_eq!(file.name, "사진.jpg");
|
||||
|
||||
assert!(parse_list_line("drwxr-xr-x 2 u g 4096 Jan 01 12:00 .").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! VFS 추상화 — 로컬/원격 소스가 스캔·인덱싱·썸네일·재생 파이프라인을 공유하기
|
||||
//! 위한 단일 트레이트. 모든 소비자는 `VfsProvider`만 알고, 프로토콜별 구현은
|
||||
//! 백엔드 모듈에 격리된다.
|
||||
|
||||
pub mod creds;
|
||||
pub mod ftp;
|
||||
pub mod local;
|
||||
pub mod sftp;
|
||||
pub mod webdav;
|
||||
|
||||
use std::ops::Range;
|
||||
use tokio::io::AsyncRead;
|
||||
|
||||
bitflags::bitflags! {
|
||||
/// 소스별 지원 기능 — UI와 파이프라인 동작을 게이트한다.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Caps: u32 {
|
||||
/// 임의 오프셋 읽기 (재생 시킹, EXIF 썸네일 부분 다운로드)
|
||||
const RANGE_READ = 1 << 0;
|
||||
/// 서버측 이동/이름변경
|
||||
const SERVER_MOVE = 1 << 1;
|
||||
/// OS 휴지통 (없으면 앱 레벨 휴지통 사용)
|
||||
const NATIVE_TRASH = 1 << 2;
|
||||
/// 수정시각 보존 쓰기
|
||||
const MTIME_WRITE = 1 << 3;
|
||||
/// 병렬 읽기에 안전
|
||||
const PARALLEL_READS = 1 << 4;
|
||||
/// 파일 변경 감시 신뢰 가능
|
||||
const WATCHABLE = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VfsError {
|
||||
#[error("I/O 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("경로를 찾을 수 없음: {0}")]
|
||||
NotFound(String),
|
||||
#[error("아직 구현되지 않음: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
#[error("프로토콜 오류: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, VfsError>;
|
||||
|
||||
/// 스트리밍 읽기 핸들.
|
||||
pub type VfsRead = Box<dyn AsyncRead + Send + Unpin>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait VfsProvider: Send + Sync {
|
||||
fn capabilities(&self) -> Caps;
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>>;
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry>;
|
||||
|
||||
/// 핵심 읽기 프리미티브. `range`가 None이면 파일 전체를 스트리밍한다.
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead>;
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()>;
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()>;
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) fn system_time_to_ms(t: std::time::SystemTime) -> i64 {
|
||||
t.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! 로컬 파일시스템 백엔드 — OS가 마운트한 네트워크 드라이브(UNC, Z:\, /Volumes)도
|
||||
//! 이 백엔드로 처리된다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
pub struct LocalFs;
|
||||
|
||||
impl LocalFs {
|
||||
pub fn new() -> Self {
|
||||
LocalFs
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_from_meta(name: String, meta: &std::fs::Metadata) -> VEntry {
|
||||
VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.len(),
|
||||
mtime_ms: meta
|
||||
.modified()
|
||||
.map(crate::system_time_to_ms)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for LocalFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ
|
||||
| Caps::SERVER_MOVE
|
||||
| Caps::NATIVE_TRASH
|
||||
| Caps::MTIME_WRITE
|
||||
| Caps::PARALLEL_READS
|
||||
| Caps::WATCHABLE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let mut rd = tokio::fs::read_dir(path).await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(e) = rd.next_entry().await? {
|
||||
let meta = match e.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue, // 접근 불가 항목은 건너뜀
|
||||
};
|
||||
out.push(entry_from_meta(e.file_name().to_string_lossy().into_owned(), &meta));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
Ok(entry_from_meta(name, &meta))
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
match range {
|
||||
None => Ok(Box::new(file)),
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
Ok(Box::new(file.take(len)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
tokio::fs::rename(from, to).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
// 주의: 하드 삭제. 휴지통 삭제는 fileops 계층(trash 크레이트)이 담당하고,
|
||||
// VFS delete는 앱 레벨 휴지통 rename 이후의 퍼지 등 내부 용도로만 쓴다.
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
if meta.is_dir() {
|
||||
return Err(VfsError::Protocol("디렉터리 삭제는 지원하지 않음".into()));
|
||||
}
|
||||
tokio::fs::remove_file(path).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
tokio::fs::create_dir_all(path).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_read_returns_exact_window() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("a.bin");
|
||||
std::fs::write(&p, b"0123456789").unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let mut r = fs
|
||||
.open_range(p.to_str().unwrap(), Some(2..6))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"2345");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_dir_and_stat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("한글파일.jpg"), b"xx").unwrap();
|
||||
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let entries = fs.list_dir(dir.path().to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
let file = entries.iter().find(|e| !e.is_dir).unwrap();
|
||||
assert_eq!(file.name, "한글파일.jpg");
|
||||
assert_eq!(file.size, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! SFTP 백엔드 (russh + russh-sftp, 순수 Rust, ring 백엔드).
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use russh::client;
|
||||
use russh_sftp::client::SftpSession;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SftpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
impl client::Handler for ClientHandler {
|
||||
type Error = russh::Error;
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
// v1: TOFU 미구현 — 서버 키를 신뢰 (LAN/개인 NAS 가정).
|
||||
// 프로덕션 강화 시 known_hosts 저장/검증 추가.
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SftpFs {
|
||||
sftp: Arc<Mutex<SftpSession>>,
|
||||
base: String,
|
||||
// 세션 유지용 핸들 (drop되면 연결 종료)
|
||||
_handle: Arc<client::Handle<ClientHandler>>,
|
||||
}
|
||||
|
||||
impl SftpFs {
|
||||
pub async fn connect(cfg: &SftpConfig) -> Result<SftpFs> {
|
||||
let config = Arc::new(client::Config::default());
|
||||
let mut handle = client::connect(config, (cfg.host.as_str(), cfg.port), ClientHandler)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 연결 실패: {e}")))?;
|
||||
let authed = handle
|
||||
.authenticate_password(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 인증 실패: {e}")))?;
|
||||
if !authed.success() {
|
||||
return Err(VfsError::Protocol("SSH 인증 거부됨".into()));
|
||||
}
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("채널 오류: {e}")))?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 서브시스템 오류: {e}")))?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 세션 오류: {e}")))?;
|
||||
|
||||
Ok(SftpFs {
|
||||
sftp: Arc::new(Mutex::new(sftp)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
_handle: Arc::new(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for SftpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let entries = sftp
|
||||
.read_dir(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("read_dir: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for e in entries {
|
||||
let name = e.file_name();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let meta = e.metadata();
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let meta = sftp
|
||||
.metadata(&full)
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let mut file = sftp
|
||||
.open(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("open: {e}")))?;
|
||||
match range {
|
||||
None => {
|
||||
// 전체를 메모리로 (SftpSession 핸들 수명 문제 회피)
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let n = file.read(&mut buf).await?;
|
||||
buf.truncate(n);
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.rename(&f, &t)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("rename: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.remove_file(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("delete: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
// 존재하면 무시
|
||||
let _ = sftp.create_dir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! WebDAV 백엔드 (reqwest + rustls). 시놀로지 WebDAV Server 대상.
|
||||
//! Range를 그대로 지원해 원격 재생 시킹에 가장 유리하다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use reqwest_dav::{Auth, Client, ClientBuilder, Depth};
|
||||
use std::ops::Range;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebDavConfig {
|
||||
pub url: String, // 예: https://nas.example.com:5006/dav
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct WebDavFs {
|
||||
client: Client,
|
||||
base: String,
|
||||
// 원격 재생용 raw HTTP (Range 헤더 직접 전달)
|
||||
http: reqwest::Client,
|
||||
root_url: String,
|
||||
auth: (String, String),
|
||||
}
|
||||
|
||||
impl WebDavFs {
|
||||
pub async fn connect(cfg: &WebDavConfig) -> Result<WebDavFs> {
|
||||
let client = ClientBuilder::new()
|
||||
.set_host(cfg.url.clone())
|
||||
.set_auth(Auth::Basic(cfg.username.clone(), cfg.password.clone()))
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(format!("WebDAV 클라이언트: {e}")))?;
|
||||
let http = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(e.to_string()))?;
|
||||
Ok(WebDavFs {
|
||||
client,
|
||||
base: cfg.base_path.trim_matches('/').to_string(),
|
||||
http,
|
||||
root_url: cfg.url.trim_end_matches('/').to_string(),
|
||||
auth: (cfg.username.clone(), cfg.password.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
fn rel(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else {
|
||||
format!("/{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
|
||||
fn abs_url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.root_url, self.rel(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for WebDavFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(1))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("PROPFIND: {e}")))?;
|
||||
// 요청 디렉터리 자신의 정규화 경로 (마지막 세그먼트)
|
||||
let req_seg = urldecode(rel.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
reqwest_dav::list_cmd::ListEntity::File(f) => {
|
||||
let name = urldecode(f.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
reqwest_dav::list_cmd::ListEntity::Folder(d) => {
|
||||
let name = urldecode(d.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
// 자기 자신(요청 디렉터리)은 제외 — 이름이 같으면 스킵
|
||||
if name.is_empty() || name == req_seg {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(0))
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = rel.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string();
|
||||
match items.into_iter().next() {
|
||||
Some(reqwest_dav::list_cmd::ListEntity::File(f)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
}),
|
||||
Some(reqwest_dav::list_cmd::ListEntity::Folder(d)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
}),
|
||||
None => Err(VfsError::NotFound(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use futures_util::TryStreamExt;
|
||||
let url = self.abs_url(path);
|
||||
let mut req = self.http.get(&url).basic_auth(&self.auth.0, Some(&self.auth.1));
|
||||
if let Some(r) = &range {
|
||||
req = req.header("Range", format!("bytes={}-{}", r.start, r.end.saturating_sub(1)));
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| VfsError::Protocol(format!("GET: {e}")))?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 206 {
|
||||
return Err(VfsError::Protocol(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
Ok(Box::new(tokio_util_compat(stream)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
self.client
|
||||
.mv(&self.rel(from), &self.rel(to))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("MOVE: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
self.client
|
||||
.delete(&self.rel(path))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("DELETE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let _ = self.client.mkcol(&self.rel(path)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// bytes 스트림을 AsyncRead로 변환
|
||||
fn tokio_util_compat<S>(stream: S) -> impl tokio::io::AsyncRead + Send + Unpin
|
||||
where
|
||||
S: futures_util::Stream<Item = std::io::Result<bytes::Bytes>> + Send + Unpin + 'static,
|
||||
{
|
||||
tokio_util::io::StreamReader::new(stream)
|
||||
}
|
||||
|
||||
/// 최소 URL 디코드 (%XX + 경로 세그먼트)
|
||||
fn urldecode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_decode() {
|
||||
assert_eq!(urldecode("%ED%95%9C%EA%B8%80"), "한글");
|
||||
assert_eq!(urldecode("plain.jpg"), "plain.jpg");
|
||||
assert_eq!(urldecode("a%20b.png"), "a b.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! WebDAV 백엔드 통합 테스트 — 자체 완결형(in-process) 최소 WebDAV 서버 대상.
|
||||
//! PROPFIND(Depth 1) + Range GET을 검증한다 (브라우즈 + 원격 재생 경로).
|
||||
|
||||
use archive_vfs::webdav::{WebDavConfig, WebDavFs};
|
||||
use archive_vfs::VfsProvider;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// 아주 작은 WebDAV 서버: 고정 디렉터리 하나(/dav)에 파일 2개.
|
||||
/// PROPFIND → multistatus XML, GET → Range 지원.
|
||||
async fn run_server() -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(&mut sock).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
const FILE_A: &[u8] = b"0123456789ABCDEF"; // 16바이트
|
||||
const FILE_B: &[u8] = b"hello webdav world"; // 18바이트
|
||||
|
||||
async fn handle(sock: &mut tokio::net::TcpStream) -> Result<(), Infallible> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let method = req.split_whitespace().next().unwrap_or("");
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/");
|
||||
|
||||
let response: Vec<u8> = match method {
|
||||
"PROPFIND" => {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response><D:href>/dav/</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
<D:response><D:href>/dav/a.jpg</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</D:getcontentlength>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
<D:response><D:href>/dav/b.mp4</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</D:getcontentlength>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
</D:multistatus>"#,
|
||||
FILE_A.len(),
|
||||
FILE_B.len()
|
||||
);
|
||||
http_response("207 Multi-Status", "application/xml", xml.as_bytes(), None)
|
||||
}
|
||||
"GET" => {
|
||||
let body = if path.contains("a.jpg") { FILE_A } else { FILE_B };
|
||||
// Range 헤더 파싱
|
||||
if let Some(range_line) = req.lines().find(|l| l.to_ascii_lowercase().starts_with("range:")) {
|
||||
let spec = range_line.split("bytes=").nth(1).unwrap_or("").trim();
|
||||
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
|
||||
let start: usize = s.trim().parse().unwrap_or(0);
|
||||
let end: usize = e.trim().parse().unwrap_or(body.len() - 1);
|
||||
let slice = &body[start..=end.min(body.len() - 1)];
|
||||
let cr = format!("bytes {}-{}/{}", start, end.min(body.len() - 1), body.len());
|
||||
http_response("206 Partial Content", "application/octet-stream", slice, Some(&cr))
|
||||
} else {
|
||||
http_response("200 OK", "application/octet-stream", body, None)
|
||||
}
|
||||
}
|
||||
_ => http_response("405 Method Not Allowed", "text/plain", b"", None),
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.flush().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_response(status: &str, ctype: &str, body: &[u8], content_range: Option<&str>) -> Vec<u8> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n",
|
||||
body.len()
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
let mut out = head.into_bytes();
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_list_and_range_read() {
|
||||
let addr = run_server().await;
|
||||
let fs = WebDavFs::connect(&WebDavConfig {
|
||||
url: format!("http://{addr}/dav"),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
base_path: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// list_dir → 파일 2개 (디렉터리 자신 제외)
|
||||
let entries = fs.list_dir("").await.unwrap();
|
||||
let files: Vec<_> = entries.iter().filter(|e| !e.is_dir).collect();
|
||||
assert_eq!(files.len(), 2, "파일 2개여야 함: {entries:?}");
|
||||
let a = files.iter().find(|e| e.name == "a.jpg").expect("a.jpg 없음");
|
||||
assert_eq!(a.size, FILE_A.len() as u64);
|
||||
|
||||
// 전체 읽기
|
||||
let mut r = fs.open_range("a.jpg", None).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, FILE_A);
|
||||
|
||||
// Range 읽기 (재생 시킹 시나리오)
|
||||
let mut r = fs.open_range("a.jpg", Some(4..10)).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, &FILE_A[4..10]);
|
||||
}
|
||||
Reference in New Issue
Block a user