use std::{ path::{Path, PathBuf}, time::SystemTime, }; use crate::{DomainError, DownloadId, ProfileId, UrlText}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum DownloadState { InProgress, Paused, Completed, Cancelled, Failed, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DownloadDestination { AskEveryTime, FixedDirectory(PathBuf), } #[derive(Clone, Debug, Eq, PartialEq)] pub struct DownloadPolicy { destination: DownloadDestination, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DownloadSecurity { Standard, 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, profile_id: ProfileId, source_url: UrlText, file_name: String, destination: DownloadDestination, target_file_path: Option, security: DownloadSecurity, state: DownloadState, received_bytes: u64, total_bytes: Option, checksum: Option, security_prompt_confirmed: bool, started_at: SystemTime, } const DANGEROUS_DOWNLOAD_EXTENSIONS: &[&str] = &[ "app", "applescript", "bat", "bash", "cmd", "com", "command", "dmg", "exe", "fish", "jar", "js", "jse", "msi", "msp", "pkg", "ps1", "psm1", "reg", "scr", "scpt", "sh", "terminal", "vbe", "vbs", "workflow", "zsh", ]; impl DownloadDestination { pub fn fixed_directory(path: impl Into) -> Result { let path = path.into(); if path.as_os_str().is_empty() { return Err(DomainError::EmptyField { field: "download_directory" }); } if !path.is_absolute() { return Err(DomainError::InvalidDownloadDirectory { path: path.display().to_string() }); } Ok(Self::FixedDirectory(path)) } #[must_use] pub fn as_path(&self) -> Option<&Path> { match self { Self::AskEveryTime => None, Self::FixedDirectory(path) => Some(path.as_path()), } } pub fn target_file_path(&self, file_name: &str) -> Result, DomainError> { validate_file_name(file_name)?; Ok(match self { Self::AskEveryTime => None, Self::FixedDirectory(path) => Some(path.join(file_name)), }) } } impl DownloadPolicy { #[must_use] pub fn ask_every_time() -> Self { Self { destination: DownloadDestination::AskEveryTime } } pub fn fixed_directory(path: impl Into) -> Result { Ok(Self { destination: DownloadDestination::fixed_directory(path)? }) } #[must_use] pub fn destination(&self) -> &DownloadDestination { &self.destination } } impl DownloadSecurity { #[must_use] pub fn for_file_name(file_name: &str) -> Self { match download_extension(file_name) { Some(extension) if is_dangerous_extension(extension) => Self::DangerousExtension, _ => Self::Standard, } } #[must_use] pub fn requires_prompt(&self) -> bool { matches!(self, Self::DangerousExtension) } } 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, source_url: UrlText, file_name: impl Into, destination: DownloadDestination, total_bytes: Option, started_at: SystemTime, ) -> Result { let file_name = file_name.into(); let file_name = file_name.trim(); if file_name.is_empty() { return Err(DomainError::EmptyField { field: "file_name" }); } validate_file_name(file_name)?; let target_file_path = destination.target_file_path(file_name)?; Ok(Self { id: DownloadId::new(), profile_id, source_url, file_name: file_name.to_string(), destination, target_file_path, security: DownloadSecurity::for_file_name(file_name), state: DownloadState::InProgress, received_bytes: 0, total_bytes, checksum: None, security_prompt_confirmed: false, started_at, }) } pub fn started_at_path( profile_id: ProfileId, source_url: UrlText, destination: DownloadDestination, target_file_path: impl Into, total_bytes: Option, started_at: SystemTime, ) -> Result { let target_file_path = target_file_path.into(); if !target_file_path.is_absolute() { return Err(DomainError::InvalidDownloadTargetPath { path: target_file_path.display().to_string(), }); } let Some(file_name) = target_file_path.file_name().and_then(|value| value.to_str()) else { return Err(DomainError::InvalidDownloadTargetPath { path: target_file_path.display().to_string(), }); }; let file_name = file_name.to_string(); validate_file_name(&file_name)?; Ok(Self { id: DownloadId::new(), profile_id, source_url, file_name: file_name.clone(), destination, target_file_path: Some(target_file_path), security: DownloadSecurity::for_file_name(&file_name), state: DownloadState::InProgress, received_bytes: 0, total_bytes, checksum: None, security_prompt_confirmed: false, started_at, }) } pub fn pause(&mut self) -> Result<(), DomainError> { self.require_state("pause", &[DownloadState::InProgress])?; self.state = DownloadState::Paused; Ok(()) } pub fn resume(&mut self) -> Result<(), DomainError> { self.require_state("resume", &[DownloadState::Paused])?; self.state = DownloadState::InProgress; Ok(()) } pub fn cancel(&mut self) -> Result<(), DomainError> { self.require_state("cancel", &[DownloadState::InProgress, DownloadState::Paused])?; self.state = DownloadState::Cancelled; Ok(()) } pub fn retry(&mut self) -> Result<(), DomainError> { self.require_state("retry", &[DownloadState::Cancelled, DownloadState::Failed])?; self.state = DownloadState::InProgress; self.received_bytes = 0; Ok(()) } pub fn update_progress(&mut self, received_bytes: u64) -> Result<(), DomainError> { self.require_state("update progress", &[DownloadState::InProgress])?; self.validate_received_bytes(received_bytes)?; self.received_bytes = received_bytes; Ok(()) } pub fn complete(&mut self, received_bytes: u64) -> Result<(), DomainError> { self.require_state("complete", &[DownloadState::InProgress])?; self.validate_received_bytes(received_bytes)?; self.received_bytes = received_bytes; self.state = DownloadState::Completed; Ok(()) } pub fn fail(&mut self) -> Result<(), DomainError> { self.require_state("fail", &[DownloadState::InProgress, DownloadState::Paused])?; self.state = DownloadState::Failed; Ok(()) } pub fn record_checksum(&mut self, checksum: DownloadChecksum) -> Result<(), DomainError> { self.require_state("record checksum", &[DownloadState::Completed])?; self.checksum = Some(checksum); Ok(()) } pub fn confirm_security_prompt(&mut self) -> Result<(), DomainError> { self.require_state("confirm security prompt", &[DownloadState::Completed])?; self.security_prompt_confirmed = true; Ok(()) } #[must_use] pub fn id(&self) -> &DownloadId { &self.id } #[must_use] pub fn profile_id(&self) -> &ProfileId { &self.profile_id } #[must_use] pub fn source_url(&self) -> &UrlText { &self.source_url } #[must_use] pub fn file_name(&self) -> &str { &self.file_name } #[must_use] pub fn destination(&self) -> &DownloadDestination { &self.destination } #[must_use] pub fn target_file_path(&self) -> Option<&Path> { self.target_file_path.as_deref() } #[must_use] pub fn security(&self) -> &DownloadSecurity { &self.security } #[must_use] pub fn requires_security_confirmation(&self) -> bool { self.security.requires_prompt() && !self.security_prompt_confirmed } #[must_use] pub fn security_prompt_confirmed(&self) -> bool { self.security_prompt_confirmed } #[must_use] pub fn state(&self) -> &DownloadState { &self.state } #[must_use] pub fn received_bytes(&self) -> u64 { self.received_bytes } #[must_use] pub fn total_bytes(&self) -> Option { 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 } fn require_state( &self, action: &'static str, allowed_states: &[DownloadState], ) -> Result<(), DomainError> { if allowed_states.iter().any(|state| state == &self.state) { return Ok(()); } Err(DomainError::InvalidDownloadTransition { action, state: self.state.as_str() }) } fn validate_received_bytes(&self, received_bytes: u64) -> Result<(), DomainError> { if let Some(total_bytes) = self.total_bytes && received_bytes > total_bytes { return Err(DomainError::InvalidDownloadProgress { received_bytes, total_bytes }); } Ok(()) } } impl DownloadState { #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::InProgress => "in_progress", Self::Paused => "paused", Self::Completed => "completed", Self::Cancelled => "cancelled", Self::Failed => "failed", } } } fn validate_file_name(file_name: &str) -> Result<(), DomainError> { let has_path_separator = file_name.chars().any(|ch| ch == '/' || ch == '\\'); let is_parent_reference = file_name == "." || file_name == ".."; let has_control_character = file_name.chars().any(char::is_control); if has_path_separator || is_parent_reference || has_control_character { return Err(DomainError::InvalidFileName { value: file_name.to_string() }); } Ok(()) } fn download_extension(file_name: &str) -> Option<&str> { let (_, extension) = file_name.rsplit_once('.')?; if extension.is_empty() { return None; } Some(extension) } 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) }