feat: send sha256

This commit is contained in:
Tien Do Nam
2026-07-26 23:26:53 +02:00
parent e435cb5140
commit e926f2b9eb
26 changed files with 668 additions and 108 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ x509-parser = { version = "0.18.0", features = ["verify"], optional = true }
[features]
default = []
crypto = ["ed25519-dalek", "rsa", "sha2"]
crypto = ["ed25519-dalek", "rsa", "sha2", "tokio-util"]
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"]
webrtc-signaling = ["tokio-tungstenite"]
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
+85
View File
@@ -1,7 +1,92 @@
use crate::model::transfer::FileContent;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
/// Buffer size used when hashing a file chunk by chunk.
const HASH_BUFFER_SIZE: usize = 64 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum HashError {
#[error("Failed to read the file: {0}")]
Io(#[from] std::io::Error),
#[error("Hashing has been cancelled")]
Cancelled,
}
pub fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Computes the SHA-256 checksum of `data`, encoded as lowercase hex.
pub fn sha256_hex(data: &[u8]) -> String {
to_hex(&sha256(data))
}
/// Computes the SHA-256 checksum of a file's content, encoded as lowercase hex.
pub async fn sha256_file_content(
content: FileContent,
cancel_token: &CancellationToken,
) -> Result<String, HashError> {
let mut hasher = Sha256::new();
match content {
FileContent::Stream(mut receiver) => loop {
let chunk = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
chunk = receiver.recv() => chunk,
};
match chunk {
Some(chunk) => hasher.update(&chunk),
None => break,
}
},
FileContent::Path(path) => {
tracing::info!("Hashing file content from path: {}", path.display());
let file = tokio::fs::File::open(&path).await?;
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
}
#[cfg(target_os = "android")]
FileContent::Fd(fd) => {
use std::os::fd::FromRawFd;
tracing::info!("Hashing file content from file descriptor: {fd}");
// SAFETY: the descriptor is owned by this call; wrapping it in a File
// transfers that ownership so it is closed once hashing finishes.
let std_file = unsafe { std::fs::File::from_raw_fd(fd) };
let file = tokio::fs::File::from_std(std_file);
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
}
}
Ok(to_hex(&hasher.finalize()))
}
/// Reads `file` to EOF, feeding every chunk into `hasher`.
async fn read_and_hash_from_file(
hasher: &mut Sha256,
mut file: tokio::fs::File,
cancel_token: &CancellationToken,
) -> Result<(), HashError> {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0u8; HASH_BUFFER_SIZE];
loop {
let read = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
read = file.read(&mut buffer) => read?,
};
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(())
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
+94
View File
@@ -0,0 +1,94 @@
#![cfg(feature = "crypto")]
use localsend::crypto::hash::{sha256_file_content, sha256_hex, HashError};
use localsend::model::transfer::FileContent;
use tokio_util::sync::CancellationToken;
/// SHA-256 of "hello world".
const HELLO_WORLD_HASH: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
#[tokio::test]
async fn hash_file_from_path() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, b"hello world").await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
tokio::fs::remove_file(&path).await.unwrap();
}
/// A file larger than the internal buffer must be hashed across multiple reads.
#[tokio::test]
async fn hash_large_file_from_path() {
let content: Vec<u8> = (0..500_000).map(|i| (i % 251) as u8).collect();
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, &content).await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, sha256_hex(&content));
tokio::fs::remove_file(&path).await.unwrap();
}
/// Hashing must stop when the token is cancelled while the file is being read.
#[tokio::test]
async fn hash_cancelled_while_reading() {
let (tx, rx) = tokio::sync::mpsc::channel(1);
let cancel_token = CancellationToken::new();
let handle = tokio::spawn({
let token = cancel_token.clone();
async move { sha256_file_content(FileContent::Stream(rx), &token).await }
});
// The sender stays alive, so hashing only ends because of the cancellation.
tx.send(bytes::Bytes::from_static(b"hello ")).await.unwrap();
cancel_token.cancel();
let result = handle.await.unwrap();
assert!(matches!(result, Err(HashError::Cancelled)));
}
/// A token that is already cancelled must not start reading at all.
#[tokio::test]
async fn hash_cancelled_before_start() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, b"hello world").await.unwrap();
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = sha256_file_content(FileContent::Path(path.clone()), &cancel_token).await;
assert!(matches!(result, Err(HashError::Cancelled)));
tokio::fs::remove_file(&path).await.unwrap();
}
#[tokio::test]
async fn hash_missing_file_fails() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
let result = sha256_file_content(FileContent::Path(path), &CancellationToken::new()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn hash_stream() {
let (tx, rx) = tokio::sync::mpsc::channel(4);
tokio::spawn(async move {
tx.send(bytes::Bytes::from_static(b"hello ")).await.unwrap();
tx.send(bytes::Bytes::from_static(b"world")).await.unwrap();
});
let hash = sha256_file_content(FileContent::Stream(rx), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
}