Add profile download policy

This commit is contained in:
2026-05-07 21:43:28 -04:00
parent 0ed8c09395
commit 6d47b26090
9 changed files with 347 additions and 12 deletions
+145 -1
View File
@@ -1,4 +1,7 @@
use std::time::SystemTime;
use std::{
path::{Path, PathBuf},
time::SystemTime,
};
use crate::{DomainError, DownloadId, ProfileId, UrlText};
@@ -11,23 +14,126 @@ pub enum DownloadState {
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 struct DownloadEntry {
id: DownloadId,
profile_id: ProfileId,
source_url: UrlText,
file_name: String,
destination: DownloadDestination,
security: DownloadSecurity,
state: DownloadState,
received_bytes: u64,
total_bytes: Option<u64>,
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<PathBuf>) -> Result<Self, DomainError> {
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()),
}
}
}
impl DownloadPolicy {
#[must_use]
pub fn ask_every_time() -> Self {
Self { destination: DownloadDestination::AskEveryTime }
}
pub fn fixed_directory(path: impl Into<PathBuf>) -> Result<Self, DomainError> {
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 DownloadEntry {
pub fn started(
profile_id: ProfileId,
source_url: UrlText,
file_name: impl Into<String>,
destination: DownloadDestination,
total_bytes: Option<u64>,
started_at: SystemTime,
) -> Result<Self, DomainError> {
@@ -36,12 +142,15 @@ impl DownloadEntry {
if file_name.is_empty() {
return Err(DomainError::EmptyField { field: "file_name" });
}
validate_file_name(file_name)?;
Ok(Self {
id: DownloadId::new(),
profile_id,
source_url,
file_name: file_name.to_string(),
destination,
security: DownloadSecurity::for_file_name(file_name),
state: DownloadState::InProgress,
received_bytes: 0,
total_bytes,
@@ -115,6 +224,16 @@ impl DownloadEntry {
&self.file_name
}
#[must_use]
pub fn destination(&self) -> &DownloadDestination {
&self.destination
}
#[must_use]
pub fn security(&self) -> &DownloadSecurity {
&self.security
}
#[must_use]
pub fn state(&self) -> &DownloadState {
&self.state
@@ -170,3 +289,28 @@ impl DownloadState {
}
}
}
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))
}
+6
View File
@@ -8,6 +8,12 @@ pub enum DomainError {
#[error("invalid URL: {value}")]
InvalidUrl { value: String },
#[error("invalid file name: {value}")]
InvalidFileName { value: String },
#[error("invalid download directory: {path}")]
InvalidDownloadDirectory { path: String },
#[error("invalid command query")]
InvalidCommand,
+3 -1
View File
@@ -12,7 +12,9 @@ mod url_text;
pub use archive::{ArchiveSource, ArchivedTab};
pub use command::{CommandIntent, CommandScope};
pub use download::{DownloadEntry, DownloadState};
pub use download::{
DownloadDestination, DownloadEntry, DownloadPolicy, DownloadSecurity, DownloadState,
};
pub use error::DomainError;
pub use history::HistoryEntry;
pub use identifiers::{DownloadId, ProfileId, SpaceId, SplitId, TabId, WebViewId};
+18 -2
View File
@@ -1,4 +1,4 @@
use crate::ProfileId;
use crate::{DownloadPolicy, ProfileId};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProfileKind {
@@ -12,12 +12,19 @@ pub struct Profile {
name: String,
color_hex: u32,
kind: ProfileKind,
download_policy: DownloadPolicy,
}
impl Profile {
#[must_use]
pub fn new(name: impl Into<String>, color_hex: u32, kind: ProfileKind) -> Self {
Self { id: ProfileId::new(), name: name.into(), color_hex, kind }
Self {
id: ProfileId::new(),
name: name.into(),
color_hex,
kind,
download_policy: DownloadPolicy::ask_every_time(),
}
}
#[must_use]
@@ -39,4 +46,13 @@ impl Profile {
pub fn kind(&self) -> &ProfileKind {
&self.kind
}
#[must_use]
pub fn download_policy(&self) -> &DownloadPolicy {
&self.download_policy
}
pub fn set_download_policy(&mut self, download_policy: DownloadPolicy) {
self.download_policy = download_policy;
}
}