Files
강 한andClaude Opus 4.8 394d1b9805 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>
2026-07-16 22:04:06 +09:00

132 lines
5.2 KiB
Rust

//! 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]);
}