From b76c8121863c8d4ce77b36e5fac5b96768a42a02 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Wed, 17 Aug 2022 12:40:58 -0400 Subject: [PATCH] Produce actual errors when downloading instead of panicing --- src/errors.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 78 +++++++++++------ 2 files changed, 272 insertions(+), 41 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index dc3cca2..31e70d2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -2,7 +2,7 @@ use core::fmt; use std::{path::{PathBuf, Path}, io}; #[derive(Debug)] -pub enum AviaryError { +pub enum AviaryUploadError { /// One of the files passed by the user simply did not exist /// /// The attached path is the file which didn't exist @@ -52,11 +52,11 @@ pub enum AviaryError { BadServerParameter, } -impl AviaryError { - pub fn from_open_error(e: io::ErrorKind, location: &Path) -> AviaryError { +impl AviaryUploadError { + pub fn from_open_error(e: io::ErrorKind, location: &Path) -> AviaryUploadError { match e { - io::ErrorKind::NotFound => AviaryError::FileDNE(location.to_owned()), - io::ErrorKind::PermissionDenied => AviaryError::ReadPermissionDenied(location.to_owned()), + io::ErrorKind::NotFound => AviaryUploadError::FileDNE(location.to_owned()), + io::ErrorKind::PermissionDenied => AviaryUploadError::ReadPermissionDenied(location.to_owned()), _ => panic!( "Received an error kind that should be impossible for {}: {:?}", location.display(), @@ -65,36 +65,36 @@ impl AviaryError { } } - pub fn from_upload_error(err: ureq::Error) -> AviaryError { + pub fn from_upload_error(err: ureq::Error) -> AviaryUploadError { match err { - ureq::Error::Status(code, msg) => AviaryError::ServerError( + ureq::Error::Status(code, msg) => AviaryUploadError::ServerError( format!("Error code {} received from server: {}", code, msg.into_string().unwrap_or(String::new()))), ureq::Error::Transport(transport) => match transport.kind() { ureq::ErrorKind::InvalidUrl => - AviaryError::BadServerParameter, + AviaryUploadError::BadServerParameter, ureq::ErrorKind::Dns => - AviaryError::ConnectionError( + AviaryUploadError::ConnectionError( format!("DNS issue: {}", transport.message().unwrap_or(""))), ureq::ErrorKind::Io => - AviaryError::ConnectionError( + AviaryUploadError::ConnectionError( format!("IO issue: {}", transport.message().unwrap_or(""))), ureq::ErrorKind::ConnectionFailed => - AviaryError::ConnectionError( + AviaryUploadError::ConnectionError( format!("Connection issue: {}", transport.message().unwrap_or(""))), ureq::ErrorKind::TooManyRedirects => - AviaryError::ServerError("Too many redirects".to_owned()), + AviaryUploadError::ServerError("Too many redirects".to_owned()), ureq::ErrorKind::BadHeader => - AviaryError::ServerError("Invalid header from server".to_owned()), + AviaryUploadError::ServerError("Invalid header from server".to_owned()), ureq::ErrorKind::BadStatus => - AviaryError::ServerError("Invalid status from server".to_owned()), + AviaryUploadError::ServerError("Invalid status from server".to_owned()), unk => panic!("Unexpected transport error kind {unk}:\n{transport}") }, } } } -impl fmt::Display for AviaryError { +impl fmt::Display for AviaryUploadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "\x1b[31mError!\x1b[0m\n")?; match self { @@ -171,3 +171,208 @@ impl fmt::Display for AviaryError { } } } + +#[derive(Debug)] +pub enum AviaryDownloadError { + /// Indicates that the key passed to the program was malformed + /// + /// There are two possibities here: + /// - The key was not valid base 64 + /// - The key was valid, but was not 32 bytes + /// + /// Which of these two was the case is designated by the associated boolean. If + /// `true`, the key was invalid base64. If `false`, it was the wrong length. + /// + /// Note that this error should only be used for keys passed to the program by the user. If a + /// key of the wrong length is listed in the index file, this should be considered a bad + /// + /// Handling: Sugguest that the user check to make sure that they copied the base64 + /// key correctly, and that it is encoded in base64. + MalformedKey(bool), + + /// The server encountered an error or misbehaved + /// + /// If this was due to a bad code, the associated data will be the error code, along + /// with the response from the server, as a String. + /// + /// If this was due to misbehavior, there will be no code, and the String will be a + /// short explaination of what went wrong. Note that misbehavior includes any *unexpected* + /// behavior, not necessarily indicating that the server is broken. + /// + /// Handling: Inform the user that something went wrong with the server, and that + /// they should try another instance and/or contact the instance admin. + ServerError(Option, String), + + /// The filesystem denied access to a path with a permission denied error + /// + /// This will either be permission denied to create a directory or permission denied to + /// create/write to a file + /// + /// The associated path is the path for which permission was denied + /// + /// Handling: Ask the user to check that they own the output directory and have + /// permission for it. Sugguest trying a different directory. + PermissionDenied(PathBuf), + + /// Some uncommon filesystem error occured + /// + /// This is a sort of wildcard error for filesystem errors which aren't likely to happen under + /// normal circumstances. Examples include: + /// - The disk is ejected while a write operation is ongoing + /// - The filesystem does not support creating directories + /// - The filesystem operation timed out + /// + /// The associated error and path are the error that triggered this and the path the operation + /// occured for. + /// + /// Handling: Assume the user is a more technical user and state as much. Give the error + /// details as well as the path. In case the user is not a technical user, provide them with a + /// link to get help / report a bug. + FilesystemError(io::Error, PathBuf), + + /// The server URL provided by the user was invalid + /// + /// Handling: Halt execution and alert the user that the server url they provided was + /// invalid, including a couple examples + BadServerParameter, + + /// Some network error prevented us from connecting to the server + /// + /// No evidence exists to indicate that this was the fault of the server rather than + /// us. + /// + /// Handling: Ask the user to validate that they can connect to the internet, and + /// sugguest that they try manually entering the URL into the browser to see if it + /// works. + ConnectionError(String), + + /// Missing or expired index + /// + /// Indicates that the server reported that the index file did not exist (404). This could be + /// due to using the wrong server URL or miscopying the file/index ID. A similar error can + /// also exist when the server has expired the index file and it has not yet been overwritten + /// by a new file. + /// + /// Handling: Ask that the user check to make sure they copied the URL correctly and that + /// they've specified the right backend server URL. If they have, alert them that it's likely + /// that the gallery has expired. + MissingIndex, + + /// One (or more) of the images in the gallery has expired + /// + /// Occurs when the index points to a URL which the server claims does not exist. This almost + /// always indicates that the image has expired. Other possibilities include: + /// - The index was incorrect even *before* it was encrypted and uploaded + /// - The key was compramised, and the server took advantage of this to substitute its own + /// index file, but then gave faulty urls?? don't know why they'd do that + /// - ??? + /// + /// Handling: Ask that the user check to make sure they copied the URL correctly and that + /// they've specified the right backend server URL. If they have, alert them that it's likely + /// that the gallery has expired. + ExpiredImage, + + /// Indicates that there was a mismatch between the key and the cyphertext + /// + /// This could mean that the key was correct, but the cyphertext was invalid (like if it wasn't + /// encrypted data at all), or it could mean that the key (although still 32 bytes of base64) + /// didn't match up with the cyphertext. + /// + /// An important reason this might occur is if the gallery expired and the server re-assigned + /// the ID to another file, and both used the .bin extension. + /// + /// Handling: If this occurs for an image where the URL and key are both provided by the + /// index, it should always be interpreted as an expiration. If it occurs for an index, ask + /// the user to verify that they key and file ID were copied correctly, and if they are + /// correct, then the gallery expired. + KeyMismatch, + + /// Indicates that the index did not conform to the protobuf spec + /// + /// This is perhaps one of the least likely errors to occur in typical usage. This indicates + /// that the index violated some basic guarantee in some way. This typically corresponds to a + /// parse error. However, this isn't simple corruption, as the index was + /// still correctly encrypted and the encryption tag matched up. + /// + /// Some examples for events which could trigger this error: + /// - The index is not valid protobuf + /// - The index specifies a key which is not 32 bytes in length + /// - The index specified a file ID which contains illegal characters + /// + /// The most likely explanations for this are as follows: + /// - The key/file id pair provided to program are valid, but point to an image rather than a + /// gallery. This is unlikely because this information is never exposed to the user. + /// - These are valid key/file id pairs, but are intended for a different project which uses + /// the same encryption scheme, stores different data / doesn't use a protobuf index. + /// - A very poorly behaved client generated this index + /// + /// The associated str will provide a more specific explanation for exactly what went wrong. + /// + /// Handling: This is incredibly unlikely to happen to an unsuspecting user. Provide the user + /// with a detailed technical description of what happened, as well as a link to report a bug, + /// in the event that they are a non-technical user who, against all odds, encountered this + /// problem. + CorruptIndex(&'static str), + + /// A file already exists in the destination folder and we don't want to overwrite it + /// + /// Handling: Alert the user that the file exists and that we won't overwrite it. Their + /// options are: + /// - Choose a new destination directory + /// - Remove the existing files + /// - Add the `--force/-f` option to force the program to overwrite the files + NotOverwriting(PathBuf), + + /// Cancelled + /// + /// The user cancelled the operation, e.g. C^c + /// + /// Handling: Exit politely and state the reason + Cancelled, +} + +impl AviaryDownloadError { + pub fn from_download_error( + is_index: bool + ) -> impl Fn(ureq::Error) -> AviaryDownloadError { + move|err: ureq::Error| { + match err { + ureq::Error::Status(code, msg) => match (code/100, code%100) { + (4, 04) => + if is_index { + AviaryDownloadError::MissingIndex + } else { + AviaryDownloadError::ExpiredImage + } + _ => AviaryDownloadError::ServerError( + Some(code), + msg.into_string().unwrap_or(String::new()) + ), + }, + ureq::Error::Transport(transport) => match transport.kind() { + ureq::ErrorKind::InvalidUrl => + if is_index { + AviaryDownloadError::BadServerParameter + } else { + AviaryDownloadError::CorruptIndex("Index lists bad characters in file ID") + }, + ureq::ErrorKind::Dns => + AviaryDownloadError::ConnectionError( + format!("DNS issue: {}", transport.message().unwrap_or(""))), + ureq::ErrorKind::Io => + AviaryDownloadError::ConnectionError( + format!("IO issue: {}", transport.message().unwrap_or(""))), + ureq::ErrorKind::ConnectionFailed => + AviaryDownloadError::ConnectionError( + format!("Connection issue: {}", transport.message().unwrap_or(""))), + ureq::ErrorKind::TooManyRedirects => + AviaryDownloadError::ServerError(Some(302), "Too many redirects".to_owned()), + ureq::ErrorKind::BadHeader => + AviaryDownloadError::ServerError(None, "Invalid header from server".to_owned()), + ureq::ErrorKind::BadStatus => + AviaryDownloadError::ServerError(None, "Invalid status from server".to_owned()), + unk => panic!("Unexpected transport error kind {unk}:\n{transport}") + }, + } + }} +} diff --git a/src/main.rs b/src/main.rs index 13f6ef6..7d0b465 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,16 +6,17 @@ mod protobuf; mod errors; use std::{borrow::Cow, io::stdout}; -use std::io::{Read, Write}; +use std::io::{Read, Write, self}; use std::fs::{File, self, OpenOptions}; use std::path::Path; -use errors::AviaryError; +use errors::AviaryUploadError; use itertools::{Itertools, Either}; use parse::{CreateArgs, Command, DownloadArgs}; use ::protobuf::Message; use sanitise_file_name::sanitise_with_options; +use crate::errors::AviaryDownloadError; use crate::protobuf::image::Format; fn trim_url<'a>(base_url: &str, url: &'a str) -> Option<&'a str> { @@ -45,7 +46,7 @@ fn main() { }; match args.command { Command::Create(create_args) => create(&*full_server, create_args), - Command::Download(download_args) => download(&*full_server, download_args), + Command::Download(download_args) => download(&*full_server, download_args).unwrap(), } } @@ -55,7 +56,7 @@ fn create(server: &str, args: CreateArgs) { .map(Path::new) .map(|path| File::open(path) .map(|file| (path, file)) - .map_err(|e| AviaryError::from_open_error(e.kind(), &path)) + .map_err(|e| AviaryUploadError::from_open_error(e.kind(), &path)) ) .partition_result(); @@ -63,8 +64,8 @@ fn create(server: &str, args: CreateArgs) { println!(" \x1b[31mError!\x1b[0m"); let (nonexistant, noread): (Vec<_>, Vec<_>) = errors.iter() .partition_map(|e| match e { - AviaryError::FileDNE(path) => Either::Left(path), - AviaryError::ReadPermissionDenied(path) => Either::Right(path), + AviaryUploadError::FileDNE(path) => Either::Left(path), + AviaryUploadError::ReadPermissionDenied(path) => Either::Right(path), other => panic!("This error should not be possible! {other:?}") }); if !nonexistant.is_empty() { @@ -86,13 +87,13 @@ fn create(server: &str, args: CreateArgs) { let mut buff = Vec::with_capacity(file.metadata()?.len() as usize); file.read_to_end(&mut buff)?; Ok((path, buff)) - })().map_err(|e| AviaryError::StreamReadError(path.to_owned(), e)) + })().map_err(|e| AviaryUploadError::StreamReadError(path.to_owned(), e)) ) .inspect(|r| if r.is_ok() { print!("\x1b[32mDone!\n\x1b[37m├─\x1b[0m Thumbnailing... ") }) .inspect(|_| drop(stdout().flush())) .map(|r| r.and_then(|(path, raw_dat)| { let (full, thumbnail, blurhash, format) = thumbnailing::thumbnail(&raw_dat) - .map_err(|_| AviaryError::ImageFormatError(path.to_owned()))?; + .map_err(|_| AviaryUploadError::ImageFormatError(path.to_owned()))?; Ok((full.map(Either::Right).unwrap_or(Either::Left(raw_dat)), thumbnail, blurhash, format)) })) .inspect(|r| if r.is_ok() { print!("\x1b[32mDone!\n\x1b[37m├─\x1b[0m Encrypting... ")}) @@ -114,7 +115,7 @@ fn create(server: &str, args: CreateArgs) { .and_then(|thumb_url| upload::put_data(&agent, server, &full_img) .map(|full_url| (key, full_url, thumb_url, blurhash, format))) - .map_err(AviaryError::from_upload_error) + .map_err(AviaryUploadError::from_upload_error) )) .map(|r| r.and_then(|(key, full_url, thumb_url, blurhash, format)| { let full_trimmed = trim_url(server, &full_url); @@ -122,7 +123,7 @@ fn create(server: &str, args: CreateArgs) { if let (Some(full_url), Some(thmb_url)) = (full_trimmed, thmb_trimmed) { Ok((key, full_url.to_owned(), thmb_url.to_owned(), blurhash, format)) } else { - Err(AviaryError::ServerError(format!("Received bad response from server: {}", full_url))) + Err(AviaryUploadError::ServerError(format!("Received bad response from server: {}", full_url))) } })) .inspect(|r| if r.is_ok() { println!("\x1b[32mDone!\n")}) @@ -138,7 +139,7 @@ fn create(server: &str, args: CreateArgs) { images: image_info, title: args.title, desc: None, - special_fields: Default::default() + special_fields: Default::default(), }; let index_key = crypto::make_key(); let encrypted_index = crypto::encrypt( @@ -149,7 +150,7 @@ fn create(server: &str, args: CreateArgs) { let encoded_key = base64::encode(index_key); print!("\x1b[0mUploading index... "); upload::put_data(&agent, server, &encrypted_index) - .map_err(|e| AviaryError::from_upload_error(e)) + .map_err(|e| AviaryUploadError::from_upload_error(e)) .map(|url| format!("{}#{}", url.trim().trim_end_matches(".bin"), &encoded_key)) }); match index_url { @@ -178,28 +179,33 @@ const SANITIZATION_OPTIONS: sanitise_file_name::Options Option six_measures_of_barley: "Unnamed-Album" }; -fn download(server: &str, args: DownloadArgs) { +fn download(server: &str, args: DownloadArgs) -> Result<(), AviaryDownloadError> { let mut download_buffer = Vec::with_capacity(5_000_000); let mut decrypt_buffer = Vec::with_capacity(5_000_000); let index_url = format!("{}/{}.bin", server, args.id); let key: [u8; 32] = base64::decode(args.key) - .expect("key is malformed b64") + .map_err(|_| AviaryDownloadError::MalformedKey(true))? .try_into() - .expect("key is the wrong size"); + .map_err(|_| AviaryDownloadError::MalformedKey(false))?; let download_agent = upload::get_agent(); let encrypted_index = upload::get_data(&download_agent, &index_url, 500_000, &mut download_buffer) - .expect("GET failed"); + .map_err(AviaryDownloadError::from_download_error(true))?; let serialized_index = crypto::decrypt(&key, &encrypted_index, &mut decrypt_buffer) - .expect("wrong key or corrupt index"); + .ok_or(AviaryDownloadError::KeyMismatch)?; let index = protobuf::index::Index::parse_from_bytes(&serialized_index) - .expect("malformed index"); + .map_err(|_| AviaryDownloadError::CorruptIndex("Index was not a valid protobuf structure"))?; + println!("{index:?}"); let dest_dir: Cow = args.output.map(Cow::Owned) .unwrap_or_else(|| index.title.map(|title| Cow::Owned(sanitise_with_options(&title, &SANITIZATION_OPTIONS).into())) .unwrap_or(Cow::Borrowed("Unnamed-Album".as_ref()))); - fs::create_dir_all(&dest_dir).expect("Failed to create destination directory"); + fs::create_dir_all(&dest_dir).map_err(|e| match e.kind() { + io::ErrorKind::PermissionDenied => + AviaryDownloadError::PermissionDenied(dest_dir.as_ref().to_owned()), + _ => AviaryDownloadError::FilesystemError(e, dest_dir.as_ref().to_owned()), + })?; for (indx, image) in index.images.into_iter().enumerate() { let extension = image.format.enum_value().map(|f| match f { Format::PNG => "png", @@ -207,26 +213,46 @@ fn download(server: &str, args: DownloadArgs) { Format::AVIF => "avif", Format::JPG => "jpg", Format::GIF => "gif", - }).unwrap_or(".unk"); + }).unwrap_or("unk"); let path = dest_dir.join(format!("{indx:03}.{extension}")); let mut dest_file = OpenOptions::new() .write(true) .truncate(true) .create(true) - .open(path) - .expect("Failed to open"); - let key = image.key.try_into().expect("Invalid key size"); + .open(&path) + .map_err(|e| match e.kind() { + io::ErrorKind::PermissionDenied => + AviaryDownloadError::PermissionDenied(path.clone()), + io::ErrorKind::AlreadyExists => + AviaryDownloadError::NotOverwriting(path.clone()), + io::ErrorKind::Interrupted => + AviaryDownloadError::Cancelled, + _ => AviaryDownloadError::FilesystemError(e, path.clone()) + })?; + let key = image.key.try_into() + .map_err(|_| + AviaryDownloadError::CorruptIndex("Key of invalid length specified in index") + )?; let encrypted_thumbnail = upload::get_data( &download_agent, &format!("{}/{}.bin", server, image.full_url), 7680_000, &mut download_buffer - ).expect("Failed to retrieve image data"); + ).map_err(AviaryDownloadError::from_download_error(false))?; let decrypted_thumb = crypto::decrypt( &key, encrypted_thumbnail, &mut decrypt_buffer - ).expect("Invalid image data referenced by index or bad key in index"); - dest_file.write_all(&decrypted_thumb).expect("Failed to write to disk"); + ).ok_or(AviaryDownloadError::ExpiredImage)?; + dest_file.write_all(&decrypted_thumb) + .map_err(|e| match e.kind() { + io::ErrorKind::PermissionDenied => + AviaryDownloadError::PermissionDenied(path.clone()), + io::ErrorKind::Interrupted => + AviaryDownloadError::Cancelled, + _ => AviaryDownloadError::FilesystemError(e, path.clone()) + })?; } + + Ok(()) }