Files
wehub-resource-sync bb087aad19
CI / format (push) Failing after 2s
CI / packaging (push) Failing after 0s
CI / test (push) Failing after 3s
chore: import upstream snapshot with attribution
2026-07-13 11:58:56 +08:00

535 lines
16 KiB
Rust

#![cfg(feature = "http")]
use bytes::Bytes;
use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::dto::ProtocolType;
use localsend::http::server::{ServerEventV2, WebSendEvent};
use localsend::http::server::{start_with_port, ServerConfigV2, WebSendConfig, WebSendI18n};
use localsend::http::state::ClientInfo;
use localsend::model::transfer::FileDto;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU16, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, oneshot};
struct TestServer {
port: u16,
/// Number of `PrepareDownload` events received.
prepare_download_events: Arc<AtomicU32>,
_stop_tx: oneshot::Sender<()>,
}
/// The content sources backing the offered files, used by the test event
/// handler to answer `FileDownload` events.
#[derive(Clone)]
enum TestFileContent {
Bytes(Bytes),
Path(PathBuf),
}
impl TestFileContent {
/// Streams the content into `tx`, mimicking how an application would
/// serve in-memory content or a file from disk.
async fn stream(self, tx: mpsc::Sender<Bytes>) {
match self {
TestFileContent::Bytes(bytes) => {
let _ = tx.send(bytes).await;
}
TestFileContent::Path(path) => {
let mut file = tokio::fs::File::open(&path)
.await
.expect("Failed to open test file");
let mut buffer = vec![0u8; 4096];
loop {
let bytes_read = file.read(&mut buffer).await.expect("Failed to read");
if bytes_read == 0 {
break; // EOF
}
if tx
.send(Bytes::copy_from_slice(&buffer[..bytes_read]))
.await
.is_err()
{
break; // client disconnected
}
}
}
}
}
}
async fn start_test_server(
web_send: Option<(WebSendConfig, HashMap<String, TestFileContent>)>,
accept: bool,
) -> TestServer {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let prepare_download_events = Arc::new(AtomicU32::new(0));
let (web_send, contents) = match web_send {
Some((config, contents)) => (Some(config), contents),
None => (None, HashMap::new()),
};
// Web send emits its own event type, independent of the v2 protocol events.
let (web_event_tx, mut web_event_rx) = mpsc::channel::<WebSendEvent>(16);
tokio::spawn({
let prepare_download_events = prepare_download_events.clone();
async move {
while let Some(event) = web_event_rx.recv().await {
match event {
WebSendEvent::PrepareDownload { decision_tx, .. } => {
prepare_download_events.fetch_add(1, Ordering::SeqCst);
let _ = decision_tx.send(accept);
}
WebSendEvent::FileDownload {
file_id,
content_tx,
..
} => {
let content = contents
.get(&file_id)
.expect("FileDownload for unknown file")
.clone();
tokio::spawn(async move {
let (tx, rx) = mpsc::channel::<Bytes>(16);
if content_tx.send(rx).is_err() {
return;
}
content.stream(tx).await;
});
}
}
}
}
});
// v2 stays enabled so the `/info` endpoint (which advertises `download`) can be
// exercised. Its event channel is unused by these tests.
let (v2_event_tx, _v2_event_rx) = tokio::sync::mpsc::channel::<ServerEventV2>(16);
let (stop_tx, stop_rx) = oneshot::channel::<()>();
// Web send is configured independently of the v2 endpoints.
let web_send = web_send.map(|mut config| {
config.event_tx = web_event_tx;
config
});
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
Some(ServerConfigV2 {
pin: None,
event_tx: v2_event_tx,
}),
web_send,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
TestServer {
port,
prepare_download_events,
_stop_tx: stop_tx,
}
}
/// Returns a free port.
///
/// A counter is used instead of binding to port 0 because the OS may hand out
/// the same just-freed ephemeral port to multiple tests running in parallel.
fn free_port() -> u16 {
static PORT_COUNTER: AtomicU16 = AtomicU16::new(41551);
loop {
let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
}
async fn wait_until_reachable(port: u16) {
for _ in 0..100 {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("Server did not become reachable on port {port}");
}
fn file_dto(id: &str, name: &str, size: u64) -> FileDto {
FileDto {
id: id.to_string(),
file_name: name.to_string(),
size,
file_type: "application/octet-stream".to_string(),
sha256: None,
preview: None,
metadata: None,
}
}
/// Creates a web send config with an in-memory text file and a file on disk.
///
/// Returns the config, the content sources for the test event handler and
/// the path of the file on disk (the caller should delete it).
fn web_send_config(
pin: Option<String>,
) -> (
WebSendConfig,
HashMap<String, TestFileContent>,
PathBuf,
Vec<u8>,
) {
let disk_content: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
let disk_path = std::env::temp_dir().join(format!("localsend-web-send-{}", uuid::Uuid::new_v4()));
std::fs::write(&disk_path, &disk_content).expect("Failed to write test file");
// The config only carries the metadata; the content is streamed by the
// test event handler when the server emits `FileDownload`.
let files = HashMap::from([
(
"file-text".to_string(),
file_dto("file-text", "message.txt", 5),
),
(
"file-disk".to_string(),
file_dto("file-disk", "dir/data.bin", disk_content.len() as u64),
),
]);
let contents = HashMap::from([
(
"file-text".to_string(),
TestFileContent::Bytes(Bytes::from_static(b"hello")),
),
(
"file-disk".to_string(),
TestFileContent::Path(disk_path.clone()),
),
]);
// The event channel is a placeholder; `start_test_server` replaces it with
// the one whose receiver handles the web send events.
let (event_tx, _event_rx) = tokio::sync::mpsc::channel::<WebSendEvent>(16);
(
WebSendConfig {
files,
pin,
i18n: WebSendI18n::default(),
event_tx,
},
contents,
disk_path,
disk_content,
)
}
fn assert_status(result: Result<impl Sized, ClientError>, expected_status: u16) {
match result {
Err(ClientError::StatusCode(err)) => assert_eq!(err.status, expected_status),
Err(err) => panic!("Expected status code {expected_status}, got error: {err:?}"),
Ok(_) => panic!("Expected status code {expected_status}, got success"),
}
}
#[tokio::test]
async fn test_web_page() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = localsend::reqwest::Client::new();
let base_url = format!("http://127.0.0.1:{}", server.port);
let response = client.get(&base_url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert!(response.text().await.unwrap().contains("LocalSend"));
let response = client
.get(format!("{base_url}/main.js"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(
response.headers()["content-type"],
"text/javascript; charset=utf-8"
);
let response = client
.get(format!("{base_url}/i18n.json"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
let i18n = response
.json::<HashMap<String, String>>()
.await
.unwrap();
assert_eq!(i18n["enterPin"], "Enter PIN");
assert!(i18n.contains_key("waiting"));
// The register/info endpoints advertise the download API.
let info = LsHttpClientV2::try_new_without_cert()
.unwrap()
.info(ProtocolType::Http, "127.0.0.1", server.port)
.await
.unwrap();
assert!(info.download);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_web_page_disabled() {
let server = start_test_server(None, true).await;
let client = localsend::reqwest::Client::new();
let base_url = format!("http://127.0.0.1:{}", server.port);
let response = client.get(&base_url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 403);
let response = client
.post(format!("{base_url}/api/localsend/v2/prepare-download"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 403);
let info = LsHttpClientV2::try_new_without_cert()
.unwrap()
.info(ProtocolType::Http, "127.0.0.1", server.port)
.await
.unwrap();
assert!(!info.download);
}
#[tokio::test]
async fn test_full_download_flow() {
let (config, contents, disk_path, disk_content) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let response = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await
.unwrap();
assert_eq!(response.info.alias, "Test Server");
assert_eq!(response.files.len(), 2);
assert_eq!(response.files["file-text"].file_name, "message.txt");
assert_eq!(server.prepare_download_events.load(Ordering::SeqCst), 1);
// Download the in-memory file.
let mut bytes = Vec::new();
client
.download_to_writer(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"file-text",
&mut bytes,
)
.await
.unwrap();
assert_eq!(bytes, b"hello");
// Download the file from disk (streamed).
let download_response = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"file-disk",
)
.await
.unwrap();
assert_eq!(
download_response.headers()["content-length"],
disk_content.len().to_string().as_str()
);
assert_eq!(
download_response.headers()["content-disposition"],
"attachment; filename=\"dir-data.bin\""
);
assert_eq!(
download_response.bytes().await.unwrap().as_ref(),
disk_content.as_slice()
);
// The session can be reused without a new permission request (e.g. page reload).
let reused = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
Some(&response.session_id),
None,
)
.await
.unwrap();
assert_eq!(reused.session_id, response.session_id);
assert_eq!(server.prepare_download_events.load(Ordering::SeqCst), 1);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_prepare_download_rejected() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), false).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await;
assert_status(result, 403);
// The rejected session must not allow downloads.
let local_ip: IpAddr = "127.0.0.1".parse().unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&local_ip.to_string(),
"file-text",
)
.await;
assert_status(result, 403);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_download_invalid_session() {
let (config, contents, disk_path, _) = web_send_config(None);
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
"unknown-session",
"file-text",
)
.await;
assert_status(result, 403);
// Valid session but unknown file.
let response = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await
.unwrap();
let result = client
.download(
ProtocolType::Http,
"127.0.0.1",
server.port,
&response.session_id,
"unknown-file",
)
.await;
assert_status(result, 403);
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_pin() {
let (config, contents, disk_path, _) = web_send_config(Some("123456".to_string()));
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
// Missing PIN.
let result = client
.prepare_download(ProtocolType::Http, "127.0.0.1", server.port, None, None)
.await;
assert_status(result, 401);
// Wrong PIN.
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("000000"),
)
.await;
assert_status(result, 401);
// Correct PIN.
client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("123456"),
)
.await
.unwrap();
let _ = std::fs::remove_file(disk_path);
}
#[tokio::test]
async fn test_pin_too_many_attempts() {
let (config, contents, disk_path, _) = web_send_config(Some("123456".to_string()));
let server = start_test_server(Some((config, contents)), true).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
for _ in 0..3 {
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("000000"),
)
.await;
assert_status(result, 401);
}
// Blocked even with the correct PIN.
let result = client
.prepare_download(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
Some("123456"),
)
.await;
assert_status(result, 429);
let _ = std::fs::remove_file(disk_path);
}