feat: verify sha256

This commit is contained in:
Tien Do Nam
2026-07-27 01:20:36 +02:00
parent e926f2b9eb
commit dd8507aa75
5 changed files with 132 additions and 19 deletions
+1 -1
View File
@@ -87,6 +87,6 @@ async fn read_and_hash_from_file(
Ok(())
}
fn to_hex(bytes: &[u8]) -> String {
pub(crate) fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
+1
View File
@@ -203,6 +203,7 @@ impl LsHttpClientV2 {
/// * 400 - Missing parameters
/// * 403 - Invalid token or IP address
/// * 409 - Blocked by another session
/// * 422 - Checksum mismatch
/// * 500 - Unknown error
pub async fn upload(
&self,
+45 -12
View File
@@ -1,3 +1,4 @@
use crate::crypto;
use bytes::Bytes;
use http_body_util::BodyExt;
use hyper::body::Incoming;
@@ -58,11 +59,28 @@ pub enum FileUploadTarget {
},
}
/// Outcome of receiving an uploaded file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SaveResult {
/// The file has been received and, if a checksum was given, it matched.
Success,
/// The body could not be read or the target failed to process it.
Failed,
/// The received bytes do not match the expected SHA-256 checksum.
HashMismatch,
}
/// Forwards the body of `req` to `target`.
pub(crate) async fn save_req_to_target(
req: Request<Incoming>,
target: FileUploadTarget,
file_size: u64,
) -> bool {
expected_sha256: Option<&str>,
) -> SaveResult {
use sha2::{Digest, Sha256};
// Resolve the target into a chunk sender and a result receiver.
let (binary_tx, result_rx) = match target {
FileUploadTarget::Stream {
@@ -103,7 +121,8 @@ pub(crate) async fn save_req_to_target(
),
};
// Forward the request body to the target.
// Forward the request body to the target, hashing it on the way if requested.
let mut hasher = expected_sha256.map(|_| Sha256::new());
let mut body = req.into_body();
let mut stream_error = false;
while let Some(frame) = body.frame().await {
@@ -115,6 +134,9 @@ pub(crate) async fn save_req_to_target(
if data.is_empty() {
continue;
}
if let Some(hasher) = &mut hasher {
hasher.update(&data);
}
if binary_tx.send(data).await.is_err() {
// The receiver is gone (dropped by the application or
// closed by the file writer after an error).
@@ -133,17 +155,28 @@ pub(crate) async fn save_req_to_target(
// Signal end of file to the receiving side.
drop(binary_tx);
match stream_error {
true => false,
false => match result_rx.await {
Ok(Ok(())) => true,
Ok(Err(err)) => {
tracing::warn!("Failed to process file: {err}");
false
}
Err(_) => false,
},
if stream_error {
return SaveResult::Failed;
}
match result_rx.await {
Ok(Ok(())) => (),
Ok(Err(err)) => {
tracing::warn!("Failed to process file: {err}");
return SaveResult::Failed;
}
Err(_) => return SaveResult::Failed,
}
if let (Some(hasher), Some(expected)) = (hasher, expected_sha256) {
let actual = crypto::hash::to_hex(&hasher.finalize());
if !actual.eq_ignore_ascii_case(expected) {
tracing::warn!("Checksum mismatch: expected {expected}, got {actual}");
return SaveResult::HashMismatch;
}
}
SaveResult::Success
}
/// Spawns a task that writes incoming chunks to a file provided by `open`.
+12 -6
View File
@@ -7,7 +7,7 @@ use crate::http::server::common::error::AppError;
use crate::http::server::common::pin::check_pin;
use crate::http::server::common::query::parse_query;
use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse};
use crate::http::server::common::save::FileUploadTarget;
use crate::http::server::common::save::{FileUploadTarget, SaveResult};
use crate::http::server::common::session::{
FileStatusV2, SessionFileV2, SessionStateV2, UploadSessionV2,
};
@@ -359,6 +359,7 @@ pub(crate) async fn upload(
let mut upload_guard = UploadGuard::new(v2.clone(), session_id.clone(), file_id.clone());
let file_size = file_dto.size;
let expected_sha256 = file_dto.sha256.clone();
let (target_tx, target_rx) = oneshot::channel::<FileUploadTarget>();
let event = ServerEventV2::FileUpload {
@@ -377,13 +378,18 @@ pub(crate) async fn upload(
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
};
let success = common::save::save_req_to_target(req, target, file_size).await;
let result =
common::save::save_req_to_target(req, target, file_size, expected_sha256.as_deref()).await;
upload_guard.finish(success).await;
upload_guard.finish(result == SaveResult::Success).await;
match success {
true => Ok(Response::new(empty_body())),
false => Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)),
match result {
SaveResult::Success => Ok(Response::new(empty_body())),
SaveResult::Failed => Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)),
SaveResult::HashMismatch => Err(AppError::Message(
StatusCode::UNPROCESSABLE_ENTITY,
"Checksum mismatch".to_string(),
)),
}
}
+73
View File
@@ -2,6 +2,7 @@
use bytes::Bytes;
use futures_util::StreamExt;
use localsend::crypto::hash::sha256_hex;
use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2, RegisterDtoV2};
@@ -368,6 +369,78 @@ async fn test_full_upload_flow() {
assert_status(result, 403);
}
#[tokio::test]
async fn test_upload_with_matching_sha256() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let bytes = b"hello".to_vec();
let mut file = file_dto("file-a", "a.bin", bytes.len() as u64);
file.sha256 = Some(sha256_hex(&bytes));
let response = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
)
.await
.unwrap()
.response
.unwrap();
upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
&bytes,
)
.await
.unwrap();
assert_eq!(server.received.lock().await["file-a"], bytes);
}
#[tokio::test]
async fn test_upload_with_mismatched_sha256() {
let server = start_test_server(None, true, None).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let bytes = b"hello".to_vec();
let mut file = file_dto("file-a", "a.bin", bytes.len() as u64);
file.sha256 = Some(sha256_hex(b"something else"));
let response = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
)
.await
.unwrap()
.response
.unwrap();
let result = upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
&bytes,
)
.await;
assert_status(result, 422);
}
#[tokio::test]
async fn test_upload_saved_to_path_by_server() {
let save_dir = std::env::temp_dir().join(format!("localsend-test-{}", uuid::Uuid::new_v4()));