Calculate download checksums
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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<DownloadChecksum, DownloadChecksumError> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<PathBuf, Box<dyn Error>> {
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(std::env::temp_dir().join(format!("ely-download-{name}-{nanos}.bin")))
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod download_checksums;
|
||||
pub mod download_files;
|
||||
|
||||
@@ -16,7 +16,7 @@ impl ElyShell {
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<InputState>,
|
||||
last_intent: Option<CommandIntent>,
|
||||
download_file_error: Option<String>,
|
||||
download_action_error: Option<String>,
|
||||
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<Self>) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user