Compare commits

..

6 commits

11 changed files with 121 additions and 69 deletions

View file

@ -37,10 +37,6 @@
- yvk repo: https://code.yavook.de/jmm/shrupl - yvk repo: https://code.yavook.de/jmm/shrupl
- sharry issue: https://github.com/eikek/sharry/issues/1659 - sharry issue: https://github.com/eikek/sharry/issues/1659
- ureq: https://stackoverflow.com/questions/59586787/rust-how-to-do-http-put-of-large-files
- hashing: https://duckduckgo.com/?q=rust+get+file+hash&t=canonical&ia=web
- https://stackoverflow.com/q/69787906
- https://github.com/RustCrypto/hashes
# Ideas # Ideas

View file

@ -1,6 +1,6 @@
use std::{fmt, io, time::Duration}; use std::{fmt, io, time::Duration};
use indicatif::{ProgressBar, ProgressDrawTarget}; use indicatif::ProgressBar;
use log::{debug, warn}; use log::{debug, warn};
use crate::{ use crate::{
@ -62,7 +62,6 @@ impl AppState {
if let Some(upl) = self.inner.peek_uploading() { if let Some(upl) = self.inner.peek_uploading() {
if bar.length().is_none() { if bar.length().is_none() {
bar.set_draw_target(ProgressDrawTarget::stderr());
bar.set_length(upl.get_size()); bar.set_length(upl.get_size());
bar.set_message(upl.get_name().to_owned()); bar.set_message(upl.get_name().to_owned());
bar.enable_steady_tick(Duration::from_millis(100)); bar.enable_steady_tick(Duration::from_millis(100));
@ -70,8 +69,7 @@ impl AppState {
bar.set_position(upl.get_offset()); bar.set_position(upl.get_offset());
// BUG in `indicatif` crate? // BUG in `indicatif` crate?
// `set_position` does not force an immediate redraw, so we also call `inc_length` here // `set_position` does not force an immediate redraw like e.g. `inc_length`
bar.inc_length(0);
} }
f(bar); f(bar);

View file

@ -90,13 +90,13 @@ fn main() {
match p { match p {
// Error 404 (File not found) // Error 404 (File not found)
error::Parameter::FileID(fid) => { error::Parameter::FileID(fid) => {
info!("requeueing file {fid:?}"); info!("retrying file {fid:?}");
state.abort_upload(); state.abort_upload();
} }
// Error 404 (Share not found) // Error 404 (Share not found)
error::Parameter::ShareID(sid) => { error::Parameter::ShareID(sid) => {
// TODO ask output::prompt_rebuild_share();
info!("rebuilding share {sid:?}"); info!("rebuilding share {sid:?}");
// rebuild share // rebuild share

View file

@ -6,7 +6,7 @@ use std::{
time::Duration, time::Duration,
}; };
use indicatif::{ProgressBar, ProgressDrawTarget}; use indicatif::ProgressBar;
use log::{info, trace}; use log::{info, trace};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -63,15 +63,7 @@ impl CacheFile {
bar: &ProgressBar, bar: &ProgressBar,
) -> error::Result<()> { ) -> error::Result<()> {
bar.set_message(format!("checking {:?}", file.get_name())); bar.set_message(format!("checking {:?}", file.get_name()));
file.check_hash(|bytes| bar.inc(bytes))
match file.check_hash(|bytes| bar.inc(bytes)) {
Ok(true) => Ok(()),
Ok(false) => Err(error::Error::unknown(format!(
"Hash mismatch for file {:?}!",
file.get_name()
))),
Err(e) => Err(e.into()),
}
} }
info!("checking files in {state:?}"); info!("checking files in {state:?}");
@ -87,7 +79,6 @@ impl CacheFile {
}; };
let bar = new_progressbar(); let bar = new_progressbar();
bar.set_draw_target(ProgressDrawTarget::stderr());
bar.set_length(total_size); bar.set_length(total_size);
bar.enable_steady_tick(Duration::from_millis(50)); bar.enable_steady_tick(Duration::from_millis(50));
@ -115,7 +106,6 @@ impl CacheFile {
info!("hashing files {files:?}"); info!("hashing files {files:?}");
let bar = new_progressbar(); let bar = new_progressbar();
bar.set_draw_target(ProgressDrawTarget::stderr());
// BOOKMARK assumption: total file size < 2 EiB // BOOKMARK assumption: total file size < 2 EiB
bar.set_length(files.iter().map(FileTrait::get_size).sum()); bar.set_length(files.iter().map(FileTrait::get_size).sum());
bar.enable_steady_tick(Duration::from_millis(50)); bar.enable_steady_tick(Duration::from_millis(50));

View file

@ -28,12 +28,15 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
StdIo(#[from] std::io::Error), StdIo(#[from] std::io::Error),
#[error("response error: {0}")] #[error("Response error: {0}")]
Response(String), Response(String),
#[error("Invalid {0}")] #[error("Invalid {0}")]
InvalidParameter(Parameter), InvalidParameter(Parameter),
#[error("Mismatch, expected {expected:?} but got {actual:?}")]
Mismatch { expected: String, actual: String },
#[error("Unknown error: {0}")] #[error("Unknown error: {0}")]
Unknown(String), Unknown(String),
} }
@ -61,14 +64,18 @@ impl Error {
Self::Response(into_string(e)) Self::Response(into_string(e))
} }
pub fn unknown(e: impl ToString) -> Self { pub fn mismatch(expected: impl ToString, actual: impl ToString) -> Self {
Self::Unknown(into_string(e)) Self::Mismatch {
expected: into_string(expected),
actual: into_string(actual),
}
} }
#[must_use] #[must_use]
pub fn is_fatal(&self) -> bool { pub fn is_fatal(&self) -> bool {
match self { match self {
Self::InvalidParameter(p) => p.is_fatal(), Self::InvalidParameter(p) => p.is_fatal(),
Self::Mismatch { .. } => true,
Self::Unknown(_) => true, Self::Unknown(_) => true,
_ => false, _ => false,
} }

View file

@ -53,12 +53,9 @@ impl Checked {
} }
} }
pub fn hash(&mut self, f: impl Fn(u64)) -> io::Result<()> { pub fn hash(&mut self, f: impl Fn(u64)) -> error::Result<()> {
if self.hash.is_some() { if self.hash.is_some() {
return Err(io::Error::other(format!( return Err(error::Error::mismatch("unhashed file", self.path.display()));
"file {:?} is already hashed!",
self.path.display()
)));
} }
self.hash = Some(super::compute_file_hash(&self.path, self.size, f)?); self.hash = Some(super::compute_file_hash(&self.path, self.size, f)?);
@ -101,7 +98,7 @@ impl<'t> FileTrait<'t> for Checked {
self.size self.size
} }
fn check_hash(&self, on_progress: impl Fn(u64)) -> io::Result<bool> { fn check_hash(&self, on_progress: impl Fn(u64)) -> error::Result<()> {
super::check_file_hash(&self.path, self.size, self.hash.as_ref(), on_progress) super::check_file_hash(&self.path, self.size, self.hash.as_ref(), on_progress)
} }
} }

View file

@ -2,22 +2,19 @@ mod checked;
mod chunk; mod chunk;
mod uploading; mod uploading;
use std::{ use std::{ffi::OsStr, fs, io::Read, path::Path};
ffi::OsStr,
fs,
io::{self, Read},
path::Path,
};
use base64ct::{Base64, Encoding}; use base64ct::{Base64, Encoding};
use blake2b_simd::Params as Blake2b; use blake2b_simd::Params as Blake2b;
pub use checked::Checked; pub use checked::Checked;
pub use chunk::Chunk; pub use chunk::Chunk;
use log::debug; use log::{debug, warn};
pub use uploading::Uploading; pub use uploading::Uploading;
fn compute_file_hash(path: &Path, size: u64, on_progress: impl Fn(u64)) -> io::Result<String> { use crate::error;
fn compute_file_hash(path: &Path, size: u64, on_progress: impl Fn(u64)) -> error::Result<String> {
let mut file = fs::File::open(path)?; let mut file = fs::File::open(path)?;
let mut hasher = Blake2b::new().hash_length(64).to_state(); let mut hasher = Blake2b::new().hash_length(64).to_state();
@ -36,9 +33,7 @@ fn compute_file_hash(path: &Path, size: u64, on_progress: impl Fn(u64)) -> io::R
} }
if bytes_read != size { if bytes_read != size {
return Err(io::Error::other(format!( return Err(error::Error::mismatch(size, bytes_read));
"Hashed {bytes_read:?} bytes, known file size {size:?}!"
)));
} }
let result = Base64::encode_string(hasher.finalize().as_bytes()); let result = Base64::encode_string(hasher.finalize().as_bytes());
@ -51,15 +46,20 @@ fn check_file_hash(
size: u64, size: u64,
hash: Option<&String>, hash: Option<&String>,
on_progress: impl Fn(u64), on_progress: impl Fn(u64),
) -> io::Result<bool> { ) -> error::Result<()> {
let Some(hash) = hash else { let Some(expected) = hash else {
debug!("no hash to check for {:?}!", path.display()); return Err(error::Error::mismatch("hash", path.display()));
return Ok(false);
}; };
let result = *hash == compute_file_hash(path, size, on_progress)?; let actual = &compute_file_hash(path, size, on_progress)?;
debug!("matches {:?}: {result:?}", *hash);
Ok(result) if expected == actual {
debug!("hash matches {expected:?}");
Ok(())
} else {
warn!("hash mismatch for file {:?}", path.display());
Err(error::Error::mismatch(expected, actual))
}
} }
pub trait FileTrait<'t> { pub trait FileTrait<'t> {
@ -80,5 +80,5 @@ pub trait FileTrait<'t> {
/// get the file's size /// get the file's size
fn get_size(&self) -> u64; fn get_size(&self) -> u64;
fn check_hash(&self, on_progress: impl Fn(u64)) -> io::Result<bool>; fn check_hash(&self, on_progress: impl Fn(u64)) -> error::Result<()>;
} }

View file

@ -7,7 +7,7 @@ use std::{
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::sharry; use crate::{error, sharry};
use super::{Checked, Chunk, FileTrait}; use super::{Checked, Chunk, FileTrait};
@ -108,7 +108,7 @@ impl<'t> FileTrait<'t> for Uploading {
self.size self.size
} }
fn check_hash(&self, on_progress: impl Fn(u64)) -> io::Result<bool> { fn check_hash(&self, on_progress: impl Fn(u64)) -> error::Result<()> {
super::check_file_hash(&self.path, self.size, self.hash.as_ref(), on_progress) super::check_file_hash(&self.path, self.size, self.hash.as_ref(), on_progress)
} }
} }

View file

@ -42,7 +42,7 @@ fn find_cause(
error.into() error.into()
} }
} }
error => error::Error::unknown(error), error => error::Error::Unknown(error.to_string()),
} }
} }

View file

@ -1,9 +1,9 @@
use std::{fmt, process, sync::LazyLock}; use std::{fmt, process, sync::LazyLock};
use console::{StyledObject, style}; use console::{StyledObject, style};
use dialoguer::{Select, theme::ColorfulTheme}; use dialoguer::{Confirm, Select, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use log::{error, info}; use log::{info, warn};
type StaticStyled<'t> = LazyLock<StyledObject<&'t str>>; type StaticStyled<'t> = LazyLock<StyledObject<&'t str>>;
@ -36,6 +36,23 @@ pub fn prompt_continue() -> bool {
selection == 0 selection == 0
} }
pub fn prompt_rebuild_share() {
let prompt = format!(
"Target Share cannot be accessed. {}",
style("Completely restart upload?").cyan()
);
let selection = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.default(true)
.interact()
.unwrap_or(false);
if selection == false {
process::exit(0);
}
}
pub fn style_all<'t, F>(strs: &[&'t str], f: F) -> Vec<String> pub fn style_all<'t, F>(strs: &[&'t str], f: F) -> Vec<String>
where where
F: Fn(StyledObject<&'t str>) -> StyledObject<&'t str>, F: Fn(StyledObject<&'t str>) -> StyledObject<&'t str>,
@ -46,7 +63,7 @@ where
#[must_use] #[must_use]
#[allow(clippy::missing_panics_doc)] #[allow(clippy::missing_panics_doc)]
pub fn new_progressbar() -> ProgressBar { pub fn new_progressbar() -> ProgressBar {
ProgressBar::hidden().with_style( ProgressBar::no_length().with_style(
ProgressStyle::with_template(&format!( ProgressStyle::with_template(&format!(
concat!( concat!(
"{{bar:50.cyan/blue}} {{msg:.magenta}}: ", "{{bar:50.cyan/blue}} {{msg:.magenta}}: ",
@ -78,7 +95,7 @@ impl Log {
pub fn handle(e: &crate::error::Error) { pub fn handle(e: &crate::error::Error) {
if e.is_fatal() { if e.is_fatal() {
// react to fatal error // react to fatal error
error!("fatal error: {e:?}"); warn!("fatal error: {e:?}");
Self::error(e); Self::error(e);
} }

View file

@ -56,9 +56,10 @@ impl TryFrom<String> for FileID {
Ok(result) Ok(result)
} else { } else {
Err(error::Error::unknown(format!( Err(error::Error::mismatch(
"Could not extract File ID from {value:?}" "<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
))) value,
))
} }
} }
} }
@ -68,16 +69,62 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_fileid_tryfrom_string() { fn valid_urls_produce_expected_file_id() {
let good = "https://example.com/api/v2/alias/upload/SID123/files/tus/FID456".to_owned(); // a handful of validlooking URLs
let good = FileID::try_from(good); let cases = vec![
assert!(good.is_ok()); (
assert_eq!(good.unwrap().as_ref(), "FID456"); "http://example.com/api/v2/alias/upload/SID123/files/tus/FID456",
"FID456",
),
(
"https://my-host:8080/api/v2/alias/upload/another-SID/files/tus/some-file-id",
"some-file-id",
),
(
"custom+scheme://host/api/v2/alias/upload/x/files/tus/y",
"y",
),
];
let bad = "https://example.com/api/v2/alias/upload//files/tus/FID456".to_owned(); // missing SID for (good, expected_fid) in cases {
assert!(FileID::try_from(bad).is_err()); let s = good.to_string();
let file_id = FileID::try_from(s.clone()).expect("URL should parse successfully");
let bad = "https://example.com/api/v2/alias/upload/SID123/files/tus/".to_owned(); // missing FID assert_eq!(
assert!(FileID::try_from(bad).is_err()); file_id.0, expected_fid,
"Expected `{}` → FileID({}), got {:?}",
good, expected_fid, file_id
);
}
}
#[test]
fn invalid_urls_return_error() {
let bad_inputs = vec![
// missing /api/v2/alias/upload
"http://example.com/files/tus/FID",
// missing /files/tus
"http://example.com/api/v2/alias/upload/SID123/FID456",
// trailing slash (doesn't match `$`)
"http://example.com/api/v2/alias/upload/SID/files/tus/FID/",
// empty fid
"http://example.com/api/v2/alias/upload/SID/files/tus/",
// random string
"just-a-random-string",
];
for bad in bad_inputs {
let err = FileID::try_from(bad.to_string()).expect_err("URL should not parse");
// make sure it's the Mismatch variant, and that it contains the original input
match err {
error::Error::Mismatch { expected, actual } => {
assert_eq!(
expected, "<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
"Error should output expected format"
);
assert_eq!(actual, bad.to_string(), "Error should echo back the input");
}
_ => panic!("Expected Error::Mismatch for input `{bad}` but got {err:?}"),
}
}
} }
} }