feat: show checksum progress by byte

This commit is contained in:
Tien Do Nam
2026-07-30 15:48:12 +02:00
parent f50bcb372b
commit fe1bc539a0
13 changed files with 725 additions and 60 deletions
+27 -12
View File
@@ -26,27 +26,38 @@ pub fn sha256_hex(data: &[u8]) -> String {
}
/// Computes the SHA-256 checksum of a file's content, encoded as lowercase hex.
///
/// `progress` is invoked with the cumulative number of bytes hashed as each
/// chunk is consumed, mirroring the upload progress callback.
pub async fn sha256_file_content(
content: FileContent,
cancel_token: &CancellationToken,
progress: impl Fn(u64),
) -> 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::Stream(mut receiver) => {
let mut hashed = 0_u64;
loop {
let chunk = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
chunk = receiver.recv() => chunk,
};
match chunk {
Some(chunk) => {
hasher.update(&chunk);
hashed += chunk.len() as u64;
progress(hashed);
}
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?;
read_and_hash_from_file(&mut hasher, file, cancel_token, progress).await?;
}
#[cfg(target_os = "android")]
FileContent::Fd(fd) => {
@@ -57,7 +68,7 @@ pub async fn sha256_file_content(
// 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?;
read_and_hash_from_file(&mut hasher, file, cancel_token, progress).await?;
}
}
@@ -69,10 +80,12 @@ async fn read_and_hash_from_file(
hasher: &mut Sha256,
mut file: tokio::fs::File,
cancel_token: &CancellationToken,
progress: impl Fn(u64),
) -> Result<(), HashError> {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0u8; HASH_BUFFER_SIZE];
let mut hashed = 0_u64;
loop {
let read = tokio::select! {
biased;
@@ -83,6 +96,8 @@ async fn read_and_hash_from_file(
break;
}
hasher.update(&buffer[..read]);
hashed += read as u64;
progress(hashed);
}
Ok(())
}
+32 -13
View File
@@ -12,26 +12,40 @@ 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();
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.
/// A file larger than the internal buffer must be hashed across multiple reads,
/// reporting the cumulative progress after each of them.
#[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();
let progress = std::sync::Mutex::new(Vec::new());
let hash = sha256_file_content(
FileContent::Path(path.clone()),
&CancellationToken::new(),
|hashed| progress.lock().unwrap().push(hashed),
)
.await
.unwrap();
assert_eq!(hash, sha256_hex(&content));
let progress = progress.into_inner().unwrap();
assert!(progress.len() > 1);
assert!(progress.windows(2).all(|pair| pair[0] < pair[1]));
assert_eq!(*progress.last().unwrap(), content.len() as u64);
tokio::fs::remove_file(&path).await.unwrap();
}
@@ -43,7 +57,7 @@ async fn hash_cancelled_while_reading() {
let handle = tokio::spawn({
let token = cancel_token.clone();
async move { sha256_file_content(FileContent::Stream(rx), &token).await }
async move { sha256_file_content(FileContent::Stream(rx), &token, |_| {}).await }
});
// The sender stays alive, so hashing only ends because of the cancellation.
@@ -63,7 +77,7 @@ async fn hash_cancelled_before_start() {
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = sha256_file_content(FileContent::Path(path.clone()), &cancel_token).await;
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();
@@ -73,7 +87,8 @@ async fn hash_cancelled_before_start() {
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;
let result =
sha256_file_content(FileContent::Path(path), &CancellationToken::new(), |_| {}).await;
assert!(result.is_err());
}
@@ -86,9 +101,13 @@ async fn hash_stream() {
tx.send(bytes::Bytes::from_static(b"world")).await.unwrap();
});
let hash = sha256_file_content(FileContent::Stream(rx), &CancellationToken::new())
.await
.unwrap();
let hash = sha256_file_content(
FileContent::Stream(rx),
&CancellationToken::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
}