From a6e14826487cee9b4394293fb5997f94b679819b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 7 May 2026 22:15:56 -0400 Subject: [PATCH] Calculate download checksums --- Cargo.lock | 1 + Cargo.toml | 1 + crates/ely_app/Cargo.toml | 1 + .../src/services/download_checksums.rs | 117 ++++++++++++++++++ crates/ely_app/src/services/mod.rs | 1 + .../shell/internal_pages/download_actions.rs | 17 ++- .../src/shell/internal_pages/downloads.rs | 26 +++- crates/ely_app/src/shell/mod.rs | 32 ++++- 8 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 crates/ely_app/src/services/download_checksums.rs diff --git a/Cargo.lock b/Cargo.lock index 5518fd3..312ef3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2202,6 +2202,7 @@ dependencies = [ "gpui", "gpui-component", "gpui-component-assets", + "sha2", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index d75d54a..2c23358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ gpui = "0.2.2" gpui-component = "0.5.1" gpui-component-assets = "0.5.1" servo = "0.1.0" +sha2 = "0.10.9" thiserror = "2.0.12" url = "2.5.4" uuid = { version = "1.12.1", features = ["v7"] } diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index 102abcd..d0ef03f 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -12,6 +12,7 @@ ely_domain = { path = "../ely_domain" } gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true +sha2.workspace = true thiserror.workspace = true [lints] diff --git a/crates/ely_app/src/services/download_checksums.rs b/crates/ely_app/src/services/download_checksums.rs new file mode 100644 index 0000000..5ce2819 --- /dev/null +++ b/crates/ely_app/src/services/download_checksums.rs @@ -0,0 +1,117 @@ +use std::{ + fs::{self, File}, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +use ely_domain::{DomainError, DownloadChecksum}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const CHECKSUM_BUFFER_BYTES: usize = 64 * 1024; + +pub struct DownloadChecksumCalculator; + +#[derive(Debug, Error)] +pub enum DownloadChecksumError { + #[error("download file is unavailable: {path}")] + FileUnavailable { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("download path is not a file: {path}")] + NotAFile { path: PathBuf }, + + #[error(transparent)] + Domain(#[from] DomainError), +} + +impl DownloadChecksumCalculator { + pub fn sha256(path: &Path) -> Result { + require_file(path)?; + let mut file = File::open(path).map_err(|source| { + DownloadChecksumError::FileUnavailable { path: path.to_path_buf(), source } + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0; CHECKSUM_BUFFER_BYTES]; + + loop { + let bytes_read = file.read(&mut buffer).map_err(|source| { + DownloadChecksumError::FileUnavailable { path: path.to_path_buf(), source } + })?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + DownloadChecksum::sha256_hex(encode_lower_hex(&hasher.finalize())).map_err(Into::into) + } +} + +fn require_file(path: &Path) -> Result<(), DownloadChecksumError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(DownloadChecksumError::NotAFile { path: path.to_path_buf() }), + Err(source) => { + Err(DownloadChecksumError::FileUnavailable { path: path.to_path_buf(), source }) + } + } +} + +fn encode_lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let byte = *byte; + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use std::{ + error::Error, + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::{DownloadChecksumCalculator, DownloadChecksumError}; + + #[test] + fn computes_sha256_for_download_file() -> Result<(), Box> { + let path = temp_file_path("checksum")?; + fs::write(&path, [])?; + + let checksum = DownloadChecksumCalculator::sha256(&path)?; + let value = checksum.value().to_string(); + fs::remove_file(path)?; + + assert_eq!(value, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + Ok(()) + } + + #[test] + fn rejects_missing_download_file() -> Result<(), Box> { + let path = temp_file_path("missing")?; + + let error = match DownloadChecksumCalculator::sha256(&path) { + Ok(_) => return Err("missing download file should be rejected".into()), + Err(error) => error, + }; + + assert!(matches!(error, DownloadChecksumError::FileUnavailable { .. })); + Ok(()) + } + + fn temp_file_path(name: &str) -> Result> { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + Ok(std::env::temp_dir().join(format!("ely-download-{name}-{nanos}.bin"))) + } +} diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index 36af1df..fa55803 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -1 +1,2 @@ +pub mod download_checksums; pub mod download_files; diff --git a/crates/ely_app/src/shell/internal_pages/download_actions.rs b/crates/ely_app/src/shell/internal_pages/download_actions.rs index 068d10e..f088d2e 100644 --- a/crates/ely_app/src/shell/internal_pages/download_actions.rs +++ b/crates/ely_app/src/shell/internal_pages/download_actions.rs @@ -16,7 +16,7 @@ impl ElyShell { cx: &mut Context, ) -> AnyElement { div() - .w(px(64.0)) + .w(px(96.0)) .flex() .items_center() .justify_end() @@ -84,6 +84,7 @@ impl ElyShell { |this| { let open_id = entry.id().clone(); let reveal_id = entry.id().clone(); + let checksum_id = entry.id().clone(); this.child( download_action_button("open", index, IconName::ExternalLink, "Open File") @@ -104,6 +105,20 @@ impl ElyShell { })) .into_any_element(), ) + .when(entry.checksum().is_none(), |this| { + this.child( + download_action_button( + "checksum", + index, + IconName::CircleCheck, + "Calculate SHA-256", + ) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.calculate_download_checksum(&checksum_id, cx); + })) + .into_any_element(), + ) + }) }, ) .into_any_element() diff --git a/crates/ely_app/src/shell/internal_pages/downloads.rs b/crates/ely_app/src/shell/internal_pages/downloads.rs index 4bd26ad..fd1aec3 100644 --- a/crates/ely_app/src/shell/internal_pages/downloads.rs +++ b/crates/ely_app/src/shell/internal_pages/downloads.rs @@ -1,6 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{DownloadEntry, DownloadSecurity}; +use ely_domain::{DownloadChecksum, DownloadEntry, DownloadSecurity}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, @@ -106,8 +106,8 @@ impl ElyShell { ), ), ) - .when_some(self.download_file_error.clone(), |this, message| { - this.child(render_download_file_error(message)) + .when_some(self.download_action_error.clone(), |this, message| { + this.child(render_download_action_error(message)) }) .when( self.download_clear_confirmation && !snapshot.download_entries.is_empty(), @@ -259,6 +259,9 @@ impl ElyShell { ) .when(entry.security().requires_prompt(), |this| { this.child(render_security_prompt(entry.security())) + }) + .when_some(entry.checksum(), |this, checksum| { + this.child(render_checksum_label(checksum)) }), ), ), @@ -296,7 +299,7 @@ impl ElyShell { } } -fn render_download_file_error(message: String) -> AnyElement { +fn render_download_action_error(message: String) -> AnyElement { div() .rounded_md() .border_1() @@ -323,3 +326,18 @@ fn render_security_prompt(security: &DownloadSecurity) -> AnyElement { .child(download_security_label(security)) .into_any_element() } + +fn render_checksum_label(checksum: &DownloadChecksum) -> AnyElement { + div() + .flex() + .items_center() + .gap_1() + .text_color(rgb(colors::SUCCESS)) + .child(IconName::CircleCheck) + .child(format!("SHA-256 {}", short_checksum(checksum.value()))) + .into_any_element() +} + +fn short_checksum(value: &str) -> &str { + value.get(..12).unwrap_or(value) +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 5fd8101..6431ad8 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -9,7 +9,10 @@ use gpui_component::input::{InputEvent, InputState, SelectAll}; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, OpenSettings, RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, - TogglePinnedTab, services::download_files::DownloadFileAction, + TogglePinnedTab, + services::{ + download_checksums::DownloadChecksumCalculator, download_files::DownloadFileAction, + }, }; enum ShellState { @@ -22,7 +25,7 @@ pub struct ElyShell { focus_handle: FocusHandle, command_input: Entity, last_intent: Option, - download_file_error: Option, + download_action_error: Option, download_clear_confirmation: bool, _command_subscription: Subscription, } @@ -79,7 +82,7 @@ impl ElyShell { focus_handle: cx.focus_handle(), command_input, last_intent: None, - download_file_error: None, + download_action_error: None, download_clear_confirmation: false, _command_subscription: command_subscription, } @@ -266,7 +269,26 @@ impl ElyShell { core.clear_downloads_for_active_profile(); } self.download_clear_confirmation = false; - self.download_file_error = None; + self.download_action_error = None; + cx.notify(); + } + + fn calculate_download_checksum(&mut self, download_id: &DownloadId, cx: &mut Context) { + let result = match &mut self.state { + ShellState::Ready(core) => core + .download_target_file_path(download_id) + .map_err(|error| error.to_string()) + .and_then(|path| { + DownloadChecksumCalculator::sha256(&path).map_err(|error| error.to_string()) + }) + .and_then(|checksum| { + core.record_download_checksum(download_id, checksum) + .map_err(|error| error.to_string()) + }), + ShellState::StartupError(message) => Err(message.clone()), + }; + + self.download_action_error = result.err(); cx.notify(); } @@ -292,7 +314,7 @@ impl ElyShell { ShellState::StartupError(message) => Err(message.clone()), }; - self.download_file_error = result.err(); + self.download_action_error = result.err(); cx.notify(); }