Record download checksums

This commit is contained in:
2026-05-07 22:07:03 -04:00
parent 7c9c9ab1e2
commit 5682434ea3
5 changed files with 142 additions and 4 deletions
+10 -1
View File
@@ -1,6 +1,6 @@
use std::{path::PathBuf, time::SystemTime}; use std::{path::PathBuf, time::SystemTime};
use ely_domain::{DownloadEntry, DownloadId, UrlText}; use ely_domain::{DownloadChecksum, DownloadEntry, DownloadId, UrlText};
use crate::CoreError; use crate::CoreError;
@@ -70,6 +70,15 @@ impl BrowserCore {
Ok(()) 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( pub fn download_target_file_path(
&self, &self,
download_id: &DownloadId, download_id: &DownloadId,
+65 -2
View File
@@ -2,10 +2,12 @@ use std::{error::Error, path::Path};
use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig};
use ely_domain::{ use ely_domain::{
DomainError, DownloadDestination, DownloadId, DownloadPolicy, DownloadSecurity, DownloadState, DomainError, DownloadChecksum, DownloadDestination, DownloadId, DownloadPolicy,
ProfileKind, UrlText, DownloadSecurity, DownloadState, ProfileKind, UrlText,
}; };
const REPORT_SHA256: &str = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";
#[test] #[test]
fn download_entries_stay_with_active_profile() -> Result<(), Box<dyn Error>> { fn download_entries_stay_with_active_profile() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
@@ -66,6 +68,67 @@ fn controls_download_lifecycle() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
fn records_checksum_after_download_completion() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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] #[test]
fn records_active_profile_download_policy_on_started_entry() -> Result<(), Box<dyn Error>> { fn records_active_profile_download_policy_on_started_entry() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
+62
View File
@@ -31,6 +31,17 @@ pub enum DownloadSecurity {
DangerousExtension, 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadEntry { pub struct DownloadEntry {
id: DownloadId, id: DownloadId,
@@ -43,6 +54,7 @@ pub struct DownloadEntry {
state: DownloadState, state: DownloadState,
received_bytes: u64, received_bytes: u64,
total_bytes: Option<u64>, total_bytes: Option<u64>,
checksum: Option<DownloadChecksum>,
started_at: SystemTime, 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<String>) -> Result<Self, DomainError> {
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 { impl DownloadEntry {
pub fn started( pub fn started(
profile_id: ProfileId, profile_id: ProfileId,
@@ -165,6 +211,7 @@ impl DownloadEntry {
state: DownloadState::InProgress, state: DownloadState::InProgress,
received_bytes: 0, received_bytes: 0,
total_bytes, total_bytes,
checksum: None,
started_at, started_at,
}) })
} }
@@ -215,6 +262,12 @@ impl DownloadEntry {
Ok(()) 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] #[must_use]
pub fn id(&self) -> &DownloadId { pub fn id(&self) -> &DownloadId {
&self.id &self.id
@@ -265,6 +318,11 @@ impl DownloadEntry {
self.total_bytes self.total_bytes
} }
#[must_use]
pub fn checksum(&self) -> Option<&DownloadChecksum> {
self.checksum.as_ref()
}
#[must_use] #[must_use]
pub fn started_at(&self) -> SystemTime { pub fn started_at(&self) -> SystemTime {
self.started_at self.started_at
@@ -330,3 +388,7 @@ fn download_extension(file_name: &str) -> Option<&str> {
fn is_dangerous_extension(extension: &str) -> bool { fn is_dangerous_extension(extension: &str) -> bool {
DANGEROUS_DOWNLOAD_EXTENSIONS.iter().any(|candidate| extension.eq_ignore_ascii_case(candidate)) 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)
}
+3
View File
@@ -11,6 +11,9 @@ pub enum DomainError {
#[error("invalid file name: {value}")] #[error("invalid file name: {value}")]
InvalidFileName { value: String }, InvalidFileName { value: String },
#[error("invalid {algorithm} download checksum: {value}")]
InvalidDownloadChecksum { algorithm: &'static str, value: String },
#[error("invalid download directory: {path}")] #[error("invalid download directory: {path}")]
InvalidDownloadDirectory { path: String }, InvalidDownloadDirectory { path: String },
+2 -1
View File
@@ -13,7 +13,8 @@ mod url_text;
pub use archive::{ArchiveSource, ArchivedTab}; pub use archive::{ArchiveSource, ArchivedTab};
pub use command::{CommandIntent, CommandScope}; pub use command::{CommandIntent, CommandScope};
pub use download::{ pub use download::{
DownloadDestination, DownloadEntry, DownloadPolicy, DownloadSecurity, DownloadState, DownloadChecksum, DownloadChecksumAlgorithm, DownloadDestination, DownloadEntry,
DownloadPolicy, DownloadSecurity, DownloadState,
}; };
pub use error::DomainError; pub use error::DomainError;
pub use history::HistoryEntry; pub use history::HistoryEntry;