From 5682434ea3606e727e18f78dca64f86193922452 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:07:03 -0400 Subject: [PATCH] Record download checksums --- .../ely_browser_core/src/state/downloads.rs | 11 ++- crates/ely_browser_core/tests/downloads.rs | 67 ++++++++++++++++++- crates/ely_domain/src/download.rs | 62 +++++++++++++++++ crates/ely_domain/src/error.rs | 3 + crates/ely_domain/src/lib.rs | 3 +- 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/crates/ely_browser_core/src/state/downloads.rs b/crates/ely_browser_core/src/state/downloads.rs index b71cf6d..a90c66b 100644 --- a/crates/ely_browser_core/src/state/downloads.rs +++ b/crates/ely_browser_core/src/state/downloads.rs @@ -1,6 +1,6 @@ use std::{path::PathBuf, time::SystemTime}; -use ely_domain::{DownloadEntry, DownloadId, UrlText}; +use ely_domain::{DownloadChecksum, DownloadEntry, DownloadId, UrlText}; use crate::CoreError; @@ -70,6 +70,15 @@ impl BrowserCore { Ok(()) } + pub fn record_download_checksum( + &mut self, + download_id: &DownloadId, + checksum: DownloadChecksum, + ) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.record_checksum(checksum)?; + Ok(()) + } + pub fn download_target_file_path( &self, download_id: &DownloadId, diff --git a/crates/ely_browser_core/tests/downloads.rs b/crates/ely_browser_core/tests/downloads.rs index fff89ee..35ff467 100644 --- a/crates/ely_browser_core/tests/downloads.rs +++ b/crates/ely_browser_core/tests/downloads.rs @@ -2,10 +2,12 @@ use std::{error::Error, path::Path}; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; use ely_domain::{ - DomainError, DownloadDestination, DownloadId, DownloadPolicy, DownloadSecurity, DownloadState, - ProfileKind, UrlText, + DomainError, DownloadChecksum, DownloadDestination, DownloadId, DownloadPolicy, + DownloadSecurity, DownloadState, ProfileKind, UrlText, }; +const REPORT_SHA256: &str = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"; + #[test] fn download_entries_stay_with_active_profile() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -66,6 +68,67 @@ fn controls_download_lifecycle() -> Result<(), Box> { Ok(()) } +#[test] +fn records_checksum_after_download_completion() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + core.complete_download(&download_id, 2048)?; + core.record_download_checksum(&download_id, DownloadChecksum::sha256_hex(REPORT_SHA256)?)?; + + let checksum = + active_download(&core)?.checksum().ok_or("download checksum should exist")?.clone(); + assert_eq!(checksum.value(), REPORT_SHA256.to_ascii_lowercase()); + Ok(()) +} + +#[test] +fn rejects_checksum_before_download_completion() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + let error = match core + .record_download_checksum(&download_id, DownloadChecksum::sha256_hex(REPORT_SHA256)?) + { + Ok(()) => return Err("checksum should require completed download".into()), + Err(error) => error, + }; + + assert_eq!( + error, + CoreError::Domain(DomainError::InvalidDownloadTransition { + action: "record checksum", + state: "in_progress" + }) + ); + Ok(()) +} + +#[test] +fn rejects_invalid_sha256_checksum() -> Result<(), Box> { + let error = match DownloadChecksum::sha256_hex("not-a-sha256") { + Ok(_) => return Err("checksum should require 64 hex characters".into()), + Err(error) => error, + }; + + assert_eq!( + error, + DomainError::InvalidDownloadChecksum { + algorithm: "sha256", + value: "not-a-sha256".to_string() + } + ); + Ok(()) +} + #[test] fn records_active_profile_download_policy_on_started_entry() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/download.rs b/crates/ely_domain/src/download.rs index 866ac1a..5af9a39 100644 --- a/crates/ely_domain/src/download.rs +++ b/crates/ely_domain/src/download.rs @@ -31,6 +31,17 @@ pub enum DownloadSecurity { DangerousExtension, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DownloadChecksumAlgorithm { + Sha256, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DownloadChecksum { + algorithm: DownloadChecksumAlgorithm, + value: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct DownloadEntry { id: DownloadId, @@ -43,6 +54,7 @@ pub struct DownloadEntry { state: DownloadState, received_bytes: u64, total_bytes: Option, + checksum: Option, started_at: SystemTime, } @@ -137,6 +149,40 @@ impl DownloadSecurity { } } +impl DownloadChecksumAlgorithm { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Sha256 => "sha256", + } + } +} + +impl DownloadChecksum { + pub fn sha256_hex(value: impl Into) -> Result { + let value = value.into(); + let value = value.trim(); + if !is_sha256_hex(value) { + return Err(DomainError::InvalidDownloadChecksum { + algorithm: DownloadChecksumAlgorithm::Sha256.as_str(), + value: value.to_string(), + }); + } + + Ok(Self { algorithm: DownloadChecksumAlgorithm::Sha256, value: value.to_ascii_lowercase() }) + } + + #[must_use] + pub fn algorithm(&self) -> &DownloadChecksumAlgorithm { + &self.algorithm + } + + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + impl DownloadEntry { pub fn started( profile_id: ProfileId, @@ -165,6 +211,7 @@ impl DownloadEntry { state: DownloadState::InProgress, received_bytes: 0, total_bytes, + checksum: None, started_at, }) } @@ -215,6 +262,12 @@ impl DownloadEntry { Ok(()) } + pub fn record_checksum(&mut self, checksum: DownloadChecksum) -> Result<(), DomainError> { + self.require_state("record checksum", &[DownloadState::Completed])?; + self.checksum = Some(checksum); + Ok(()) + } + #[must_use] pub fn id(&self) -> &DownloadId { &self.id @@ -265,6 +318,11 @@ impl DownloadEntry { self.total_bytes } + #[must_use] + pub fn checksum(&self) -> Option<&DownloadChecksum> { + self.checksum.as_ref() + } + #[must_use] pub fn started_at(&self) -> SystemTime { self.started_at @@ -330,3 +388,7 @@ fn download_extension(file_name: &str) -> Option<&str> { fn is_dangerous_extension(extension: &str) -> bool { DANGEROUS_DOWNLOAD_EXTENSIONS.iter().any(|candidate| extension.eq_ignore_ascii_case(candidate)) } + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit) +} diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index cbea4fb..dc271db 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -11,6 +11,9 @@ pub enum DomainError { #[error("invalid file name: {value}")] InvalidFileName { value: String }, + #[error("invalid {algorithm} download checksum: {value}")] + InvalidDownloadChecksum { algorithm: &'static str, value: String }, + #[error("invalid download directory: {path}")] InvalidDownloadDirectory { path: String }, diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 1d68940..7208a47 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -13,7 +13,8 @@ mod url_text; pub use archive::{ArchiveSource, ArchivedTab}; pub use command::{CommandIntent, CommandScope}; pub use download::{ - DownloadDestination, DownloadEntry, DownloadPolicy, DownloadSecurity, DownloadState, + DownloadChecksum, DownloadChecksumAlgorithm, DownloadDestination, DownloadEntry, + DownloadPolicy, DownloadSecurity, DownloadState, }; pub use error::DomainError; pub use history::HistoryEntry;