use std::{ fs, io, path::{Path, PathBuf}, }; use serde::{Deserialize, Serialize}; use crate::sharry; use super::{FileTrait, Uploading}; /// Description of an existing, regular file /// /// - impl `Clone` for `clap` compatibility /// - impl `serde` for cachefile handling /// - impl `PartialEq..Ord` to handle multiple files given /// - impl `AsRef<[u8]>` for hashing with `blake2b_simd` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Checked { /// canonical path to a regular file path: PathBuf, /// size of that file size: u64, /// hash of that file hash: Option, } impl AsRef<[u8]> for Checked { fn as_ref(&self) -> &[u8] { self.path.as_os_str().as_encoded_bytes() } } impl Checked { pub(super) fn new_direct(path: PathBuf, size: u64, hash: Option) -> Self { Self { path, size, hash } } /// create a new checked file from some path reference /// /// # Errors /// /// - from `fs::metadata(path)` or `fs::canonicalize` /// - given path does not correspond to a regular file pub fn new(value: impl AsRef) -> io::Result { let meta = fs::metadata(&value)?; if meta.is_file() { Ok(Self { path: fs::canonicalize(&value)?, size: meta.len(), hash: None, }) } else { Err(io::Error::new( io::ErrorKind::InvalidInput, "Not a regular file", )) } } /// calculate and store hash for this file /// /// # Errors /// /// - from `file::compute_hash` /// - Mismatch if file already hashed /// /// TODO this could use an error variant like `IllegalInvocation` pub fn hash(&mut self, on_progress: impl FnMut(u64)) -> crate::Result<()> { if self.hash.is_some() { return crate::Error::mismatch("unhashed file", self.path.display()); } self.hash = Some(super::compute_hash(&self.path, self.size, on_progress)?); Ok(()) } /// start uploading this file /// /// - try to create a new file using the client /// - consume `self` into a `file::Uploading` struct /// /// # Errors /// /// - from `sharry::Client::file_create` pub fn start_upload( self, client: &impl sharry::Client, uri: &sharry::Uri, alias_id: &sharry::AliasID, share_id: &sharry::ShareID, ) -> crate::Result { let file_id = client.file_create(uri, alias_id, share_id, &self)?; Ok(Uploading::new_direct( self.path, self.size, self.hash, file_id, )) } } impl FileTrait for Checked { fn get_name(&self) -> &str { ::extract_file_name(&self.path) } fn get_size(&self) -> u64 { self.size } fn check_hash(&self, on_progress: impl FnMut(u64)) -> crate::Result<()> { super::check_hash(&self.path, self.size, self.hash.as_deref(), on_progress) } } #[cfg(test)] mod tests { use tempfile::{NamedTempFile, TempDir}; use crate::test_util::{ MockClient, check_trait, create_file, data::{HASHES_STD_GOOD, cases, data}, }; use super::*; fn create_checked(content: &[u8]) -> (Checked, NamedTempFile) { let file = create_file(content); let chk = Checked::new(file.path()).expect("creating `Checked` should succeed"); // return both, so the `NamedTempFile` is not auto-deleted here (chk, file) } #[test] fn new_on_existing_file_works() { for (content, size) in cases() { let (chk, file) = create_checked(content); let path = file .path() .canonicalize() .expect("the file should have a canonical path"); assert_eq!(chk.path, path); assert_eq!(chk.size, size); assert!(chk.hash.is_none()); // `FileTrait` assert_eq!( chk.get_name(), file.path().file_name().expect("`file_name` should succeed") ); assert_eq!(chk.get_size(), size); check_trait( chk.as_ref(), path.as_os_str().as_encoded_bytes(), "AsRef", "Checked", ); // new_direct let chk = Checked::new_direct(chk.path, chk.size, chk.hash); assert_eq!(chk.path, path); assert_eq!(chk.size, size); assert!(chk.hash.is_none()); } } #[test] fn new_on_dir_errors() { let tempdir = TempDir::new().expect("creating temp dir"); let fs_root = PathBuf::from("/"); let dirs = [tempdir.path(), fs_root.as_path()]; for p in dirs { let err = Checked::new(p).expect_err("creating `Checked` should fail"); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); #[cfg(target_os = "linux")] assert_eq!(err.to_string(), "Not a regular file"); } } #[test] fn new_on_nex_errors() { let tempdir = TempDir::new().expect("creating temp dir"); let nex_paths = [0, 1, 2, 3, 4].map(|i| tempdir.path().join(format!("nex_{i}.ext"))); for p in nex_paths { let err = Checked::new(p).expect_err("creating `Checked` should fail"); assert_eq!(err.kind(), io::ErrorKind::NotFound); #[cfg(target_os = "linux")] assert_eq!(err.to_string(), "No such file or directory (os error 2)"); } } #[test] fn hashing_works() { for (content, hash) in data().zip(HASHES_STD_GOOD) { let (mut chk, _file) = create_checked(content); chk.hash(drop).expect("`hash` should succeed"); // `FileTrait` chk.check_hash(drop).expect("`check_hash` should succeed"); assert_eq!(chk.hash, Some(hash.to_string())); } } #[test] fn hashing_again_errors() { for content in data() { let (mut chk, _file) = create_checked(content); // fake hash chk.hash = Some(String::default()); let err = chk.hash(drop).expect_err("`hash` twice should fail"); assert!(err.is_mismatch("unhashed file", chk.path.display().to_string())); } } #[test] fn start_upload_works() { let client = MockClient::default(); let share_id = client.add_share(); for content in data() { let (chk, _file) = create_checked(content); assert!( chk.start_upload( &client, &sharry::Uri::from(true), &sharry::AliasID::from(true), &share_id ) .is_ok() ); } } }