From 88c6ce94de9ae4fa814466858ce26697ec940c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= <40151420+ldericher@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:25:00 +0000 Subject: [PATCH] reimplementation of sharry::file submodule - compileable --- .vscode/settings.json | 1 + src/appstate.rs | 30 +++-- src/cli.rs | 10 +- src/main.rs | 18 +-- src/sharry/file/{chunks.rs => _chunks.rs} | 27 ++-- src/sharry/file/_mod.rs | 142 ++++++++++++++++++++++ src/sharry/file/checked.rs | 80 ++++++++++++ src/sharry/file/mod.rs | 137 +-------------------- src/sharry/file/uploading.rs | 93 ++++++++++++++ src/sharry/mod.rs | 2 +- 10 files changed, 372 insertions(+), 168 deletions(-) rename src/sharry/file/{chunks.rs => _chunks.rs} (70%) create mode 100644 src/sharry/file/_mod.rs create mode 100644 src/sharry/file/checked.rs create mode 100644 src/sharry/file/uploading.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 57afccf..936ca88 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "source.organizeImports": "explicit" }, }, + "rust-analyzer.imports.prefix": "plain", // // override the default setting (`cargo check --all-targets`) which produces the following error // // "can't find crate for `test`" when the default compilation target is a no_std target // "rust-analyzer.checkOnSave.allTargets": false, diff --git a/src/appstate.rs b/src/appstate.rs index 583c21a..ed01d39 100644 --- a/src/appstate.rs +++ b/src/appstate.rs @@ -1,7 +1,7 @@ use std::{ fs, io::{self, Write}, - path::Path, + path::{Path, PathBuf}, }; use log::{debug, trace}; @@ -9,22 +9,31 @@ use serde::{Deserialize, Serialize}; use super::{ cli::Cli, - sharry::{Alias, File, Share}, + sharry::{Alias, FileChecked, FileUploading, Share}, }; #[derive(Serialize, Deserialize, Debug)] pub struct AppState { alias: Alias, share: Share, - files: Vec, + checked: Vec, + uploading: Vec, +} + +fn get_cachefile(args: &Cli) -> Option { + let file_name: PathBuf = dirs_next::cache_dir()? + .join("shrupl") + .join(format!("{}.json", args.get_hash())); + + trace!("cachefile: {}", file_name.display()); + + Some(file_name) } impl AppState { fn load(file_name: impl AsRef) -> io::Result { let content = fs::read_to_string(file_name)?; - let state = serde_json::from_str(&content).map_err(io::Error::other)?; - - Ok(state) + serde_json::from_str(&content).map_err(io::Error::other) } fn save(&self, file_name: impl AsRef) -> io::Result<()> { @@ -36,11 +45,12 @@ impl AppState { } pub fn try_resume(args: &Cli) -> Option { - let file_name = dirs_next::cache_dir()? - .join("shrupl") - .join(format!("{}.json", args.get_hash())); + let file_name = get_cachefile(args)?; - trace!("loading from {}", file_name.display()); + // let content = fs::read_to_string(&file_name).ok()?; + // serde_json::from_str(&content) + // .inspect_err(|e| debug!("could not resume from {}: {e}", &file_name.display())) + // .ok() Self::load(&file_name) .inspect_err(|e| debug!("could not resume from {}: {e}", file_name.display())) diff --git a/src/cli.rs b/src/cli.rs index a83a1a3..fd5da89 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::{ use clap::{Parser, builder::PossibleValuesParser}; -use super::sharry::{Alias, File, NewShareRequest, Uri}; +use super::sharry::{Alias, FileChecked, NewShareRequest, Uri}; #[derive(Parser, Debug, Hash)] #[command(version, about, long_about = None)] @@ -50,15 +50,15 @@ pub struct Cli { /// Files to upload to the new share #[arg(value_name = "FILE", required = true, value_parser = parse_sharry_file)] - pub files: Vec, + pub files: Vec, } fn parse_seconds(data: &str) -> Result { data.parse().or(Ok(0)).map(Duration::from_secs) } -fn parse_sharry_file(data: &str) -> Result { - File::new(data).map_err(|e| e.to_string()) +fn parse_sharry_file(data: &str) -> Result { + FileChecked::new(data).map_err(|e| e.to_string()) } impl Cli { @@ -76,7 +76,7 @@ impl Cli { pub fn get_hash(&self) -> String { let file_refs = { - let mut refs: Vec<_> = self.files.iter().map(File::get_path).collect(); + let mut refs: Vec<_> = self.files.iter().map(FileChecked::get_path).collect(); refs.sort_unstable(); refs diff --git a/src/main.rs b/src/main.rs index a4b1347..6d594f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,18 +31,18 @@ fn main() { info!("share: {share:?}"); for file in args.files { - let file = file.create(&agent, &alias, &share).unwrap(); + let file = file.start_upload(&agent, &alias, &share).unwrap(); info!("file: {file:?}"); - for chunk in file.chunked(args.chunk_size * 1024 * 1024).seek(0) { - info!("chunk: {chunk:?}"); + // for chunk in file.chunked(args.chunk_size * 1024 * 1024).seek(0) { + // info!("chunk: {chunk:?}"); - file.upload_chunk(&agent, &alias, &chunk) - .unwrap_or_else(|e| { - error!("error: {e}"); - panic!("{e}"); - }); - } + // file.upload_chunk(&agent, &alias, &chunk) + // .unwrap_or_else(|e| { + // error!("error: {e}"); + // panic!("{e}"); + // }); + // } } share.notify(&agent, &alias).unwrap(); diff --git a/src/sharry/file/chunks.rs b/src/sharry/file/_chunks.rs similarity index 70% rename from src/sharry/file/chunks.rs rename to src/sharry/file/_chunks.rs index 96b800a..de484cd 100644 --- a/src/sharry/file/chunks.rs +++ b/src/sharry/file/_chunks.rs @@ -6,7 +6,9 @@ use std::{ }; use log::error; +// use serde::{Deserialize, Serialize}; +#[derive(Debug)] pub struct FileChunks<'t> { file_path: &'t Path, offset: u64, @@ -37,19 +39,22 @@ impl Iterator for FileChunks<'_> { fn next(&mut self) -> Option { let offset = self.offset; - let mut f = File::open(self.file_path) - .inspect_err(|e| error!("Error opening file: {e}")) - .ok()?; - f.seek(SeekFrom::Start(offset)).ok()?; + let bytes = { + let mut f = File::open(self.file_path) + .inspect_err(|e| error!("Error opening file: {e}")) + .ok()?; + f.seek(SeekFrom::Start(offset)).ok()?; - let mut bytes = vec![0; self.chunk_size]; - let read_len = (f.read(&mut bytes)) - .inspect_err(|e| error!("Error reading file: {e}")) - .ok()?; - bytes.truncate(read_len); + let mut bytes = vec![0; self.chunk_size]; + let read_len = (f.read(&mut bytes)) + .inspect_err(|e| error!("Error reading file: {e}")) + .ok()?; + bytes.truncate(read_len); - let read_len: u64 = read_len - .try_into() + bytes + }; + + let read_len: u64 = (bytes.len().try_into()) .inspect_err(|e| error!("Error converting to u64: {e}")) .ok()?; self.offset += read_len; diff --git a/src/sharry/file/_mod.rs b/src/sharry/file/_mod.rs new file mode 100644 index 0000000..e230f0c --- /dev/null +++ b/src/sharry/file/_mod.rs @@ -0,0 +1,142 @@ +mod chunks; + +use std::{ + ffi::OsStr, + fs::{canonicalize, metadata}, + hash::{Hash, Hasher}, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +use log::{debug, error}; +use serde::{Deserialize, Serialize}; +use ureq::{Error::Other, http::StatusCode}; + +use super::{ + alias::{Alias, SharryAlias}, + share::Share, +}; +pub use chunks::{Chunk, FileChunks}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct File { + abs_path: PathBuf, + name: String, + size: u64, + patch_uri: Option, +} + +impl Hash for File { + fn hash(&self, state: &mut H) { + self.abs_path.hash(state); + } +} + +impl PartialEq for File { + fn eq(&self, other: &Self) -> bool { + self.abs_path == other.abs_path + } +} + +impl Eq for File {} + +impl File { + pub fn new(path: impl AsRef) -> io::Result { + let abs_path = canonicalize(path)?; + + let m = metadata(&abs_path)?; + if !m.is_file() { + return Err(io::Error::new(ErrorKind::NotFound, "not a file")); + } + + let name = (abs_path.file_name().and_then(OsStr::to_str)) + .ok_or_else(|| io::Error::new(ErrorKind::NotFound, "bad file name"))? + .to_string(); + + Ok(Self { + abs_path, + name, + size: m.len(), + patch_uri: None, + }) + } + + pub fn get_path(&self) -> &Path { + &self.abs_path + } + + pub fn create( + self, + http: &ureq::Agent, + alias: &Alias, + share: &Share, + ) -> Result { + if self.patch_uri.is_some() { + return Err(Other("patch_uri already set".into())); + } + + let endpoint = alias.get_endpoint(format!("alias/upload/{}/files/tus", share.id)); + + let res = (http.post(endpoint)) + .sharry_header(alias) + .header("Sharry-File-Name", &self.name) + .header("Upload-Length", self.size) + .send_empty()?; + + if res.status() != StatusCode::CREATED { + return Err(Other("unexpected response status".into())); + } + + let location = (res.headers().get("Location")) + .ok_or_else(|| Other("Location header not found".into()))? + .to_str() + .map_err(|_| Other("Location header invalid".into()))? + .to_string(); + + debug!("received uri: {location}"); + + Ok(Self { + abs_path: self.abs_path, + name: self.name, + size: self.size, + patch_uri: Some(location), + }) + } + + pub fn chunked(&self, chunk_size: usize) -> FileChunks { + FileChunks::new(&self.abs_path, chunk_size) + } + + pub fn upload_chunk( + &self, + http: &ureq::Agent, + alias: &Alias, + chunk: &Chunk, + ) -> Result<(), ureq::Error> { + let patch_uri = (self.patch_uri.as_ref()).ok_or_else(|| Other("unset patch_uri".into()))?; + + debug!("upload uri: {patch_uri:?}"); + + let res = (http.patch(patch_uri)) + .sharry_header(alias) + .header("Upload-Offset", chunk.offset) + .send(&chunk.bytes)?; + + if res.status() != StatusCode::NO_CONTENT { + return Err(Other("unexpected response status".into())); + } + + let offset = (res.headers().get("Upload-Offset")) + .ok_or_else(|| Other("Upload-Offset header not found".into()))? + .to_str() + .map_err(|e| Other(e.into()))? + .parse::() + .map_err(|e| Other(e.into()))?; + + if chunk.after() != offset { + return Err(Other("unexpected offset response".into())); + } + + Ok(()) + } +} diff --git a/src/sharry/file/checked.rs b/src/sharry/file/checked.rs new file mode 100644 index 0000000..d94c2a4 --- /dev/null +++ b/src/sharry/file/checked.rs @@ -0,0 +1,80 @@ +use std::{ + ffi::OsStr, + fs, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +use log::debug; +use serde::{Deserialize, Serialize}; +use ureq::http::StatusCode; + +use super::{Alias, FileUploading, Share, SharryAlias}; + +#[derive(Serialize, Deserialize, Debug, Clone, Hash)] +pub struct FileChecked { + path: PathBuf, +} + +impl FileChecked { + pub fn new(value: impl AsRef) -> io::Result { + let meta = fs::metadata(&value)?; + if meta.is_file() { + Ok(Self { + path: fs::canonicalize(&value)?, + }) + } else { + Err(io::Error::new( + ErrorKind::InvalidInput, + "Not a regular file", + )) + } + } + + pub fn get_path(&self) -> &Path { + &self.path + } + + pub fn start_upload( + self, + http: &ureq::Agent, + alias: &Alias, + share: &Share, + ) -> io::Result { + let size = usize::try_from(fs::metadata(&self.path)?.len()).map_err(io::Error::other)?; + + let res = { + let endpoint = alias.get_endpoint(format!("alias/upload/{}/files/tus", share.id)); + + let name = (self.path.file_name().and_then(OsStr::to_str)) + .ok_or_else(|| io::Error::new(ErrorKind::NotFound, "bad file name"))? + .to_string(); + + (http.post(endpoint)) + .sharry_header(alias) + .header("Sharry-File-Name", &name) + .header("Upload-Length", size) + .send_empty() + .map_err(io::Error::other)? + }; + + if res.status() != StatusCode::CREATED { + return Err(io::Error::other("unexpected response status")); + } + + let location = (res.headers().get("Location")) + .ok_or_else(|| io::Error::other("Location header not found"))? + .to_str() + .map_err(|_| io::Error::other("Location header invalid"))? + .to_string(); + + debug!("patch uri: {location}"); + + Ok(FileUploading { + path: self.path, + size, + uri: location, + offset: 0, + }) + } +} diff --git a/src/sharry/file/mod.rs b/src/sharry/file/mod.rs index 6abae89..ffae74c 100644 --- a/src/sharry/file/mod.rs +++ b/src/sharry/file/mod.rs @@ -1,134 +1,7 @@ -mod chunks; +mod checked; +mod uploading; -use std::{ - ffi::OsStr, - fs::{canonicalize, metadata}, - hash::{Hash, Hasher}, - io::{self, ErrorKind}, - path::{Path, PathBuf}, -}; +pub use checked::FileChecked; +pub use uploading::FileUploading; -use log::{debug, error}; -use serde::{Deserialize, Serialize}; -use ureq::{Error::Other, http::StatusCode}; - -use super::{ - alias::{Alias, SharryAlias}, - share::Share, -}; -pub use chunks::{Chunk, FileChunks}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct File { - abs_path: PathBuf, - name: String, - size: u64, - patch_uri: Option, -} - -impl Hash for File { - fn hash(&self, state: &mut H) { - self.abs_path.hash(state); - } -} - -impl File { - pub fn new(path: impl AsRef) -> io::Result { - let abs_path = canonicalize(path)?; - - let m = metadata(&abs_path)?; - if !m.is_file() { - return Err(io::Error::new(ErrorKind::NotFound, "not a file")); - } - - let name = (abs_path.file_name().and_then(OsStr::to_str)) - .ok_or_else(|| io::Error::new(ErrorKind::NotFound, "bad file name"))? - .to_string(); - - Ok(Self { - abs_path, - name, - size: m.len(), - patch_uri: None, - }) - } - - pub fn get_path(&self) -> &Path { - &self.abs_path - } - - pub fn create( - self, - http: &ureq::Agent, - alias: &Alias, - share: &Share, - ) -> Result { - if self.patch_uri.is_some() { - return Err(Other("patch_uri already set".into())); - } - - let endpoint = alias.get_endpoint(format!("alias/upload/{}/files/tus", share.id)); - - let res = (http.post(endpoint)) - .sharry_header(alias) - .header("Sharry-File-Name", &self.name) - .header("Upload-Length", self.size) - .send_empty()?; - - if res.status() != StatusCode::CREATED { - return Err(Other("unexpected response status".into())); - } - - let location = (res.headers().get("Location")) - .ok_or_else(|| Other("Location header not found".into()))? - .to_str() - .map_err(|_| Other("Location header invalid".into()))? - .to_string(); - - debug!("received uri: {location}"); - - Ok(Self { - abs_path: self.abs_path, - name: self.name, - size: self.size, - patch_uri: Some(location), - }) - } - - pub fn chunked(&self, chunk_size: usize) -> FileChunks { - FileChunks::new(&self.abs_path, chunk_size) - } - - pub fn upload_chunk( - &self, - http: &ureq::Agent, - alias: &Alias, - chunk: &Chunk, - ) -> Result<(), ureq::Error> { - let patch_uri = (self.patch_uri.as_ref()).ok_or_else(|| Other("unset patch_uri".into()))?; - - debug!("upload uri: {patch_uri:?}"); - - let res = (http.patch(patch_uri)) - .sharry_header(alias) - .header("Upload-Offset", chunk.offset) - .send(&chunk.bytes)?; - - if res.status() != StatusCode::NO_CONTENT { - return Err(Other("unexpected response status".into())); - } - - let offset = (res.headers().get("Upload-Offset")) - .ok_or_else(|| Other("Upload-Offset header not found".into()))? - .to_str() - .map_err(|e| Other(e.into()))? - .parse::() - .map_err(|e| Other(e.into()))?; - - if chunk.after() != offset { - return Err(Other("unexpected offset response".into())); - } - - Ok(()) - } -} +use super::{Alias, Share, alias::SharryAlias}; diff --git a/src/sharry/file/uploading.rs b/src/sharry/file/uploading.rs new file mode 100644 index 0000000..6652d74 --- /dev/null +++ b/src/sharry/file/uploading.rs @@ -0,0 +1,93 @@ +use std::{ + fs::File, + io::{self, Read, Seek, SeekFrom}, + path::PathBuf, +}; + +use log::debug; +use serde::{Deserialize, Serialize}; +use ureq::{ + Error::Other, + http::{HeaderValue, StatusCode}, +}; + +use super::{Alias, SharryAlias}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct FileUploading { + pub(super) path: PathBuf, + pub(super) size: usize, + pub(super) uri: String, + pub(super) offset: usize, +} + +pub enum UploadError { + FileIO(io::Error), + Request, + ResponseStatus, + ResponseOffset, +} + +pub enum ChunkState { + Ok(FileUploading), + Err(FileUploading, UploadError), + Finished, +} + +impl FileUploading { + fn read_chunk(&self, chunk_size: usize) -> io::Result> { + let offset = u64::try_from(self.offset).map_err(io::Error::other)?; + + let mut f = File::open(&self.path)?; + f.seek(SeekFrom::Start(offset))?; + + let mut bytes = vec![0; chunk_size]; + let read_len = f.read(&mut bytes)?; + bytes.truncate(read_len); + + Ok(bytes) + } + + pub fn upload_chunk( + mut self, + http: &ureq::Agent, + alias: &Alias, + chunk_size: usize, + ) -> ChunkState { + let chunk = match self.read_chunk(chunk_size) { + Err(e) => return ChunkState::Err(self, UploadError::FileIO(e)), + Ok(value) => value, + }; + + let Ok(res) = (http.patch(&self.uri)) + .sharry_header(alias) + .header("Upload-Offset", self.offset) + .send(&chunk) + else { + return ChunkState::Err(self, UploadError::Request); + }; + + if res.status() != StatusCode::NO_CONTENT { + return ChunkState::Err(self, UploadError::ResponseStatus); + } + + let Some(Ok(Ok(res_offset))) = (res.headers().get("Upload-Offset")) + .map(HeaderValue::to_str) + .map(|v| v.map(str::parse::)) + else { + return ChunkState::Err(self, UploadError::ResponseOffset); + }; + + if self.offset + chunk.len() != res_offset { + return ChunkState::Err(self, UploadError::ResponseOffset); + } + + self.offset += res_offset; + + if self.offset == self.size { + return ChunkState::Finished; + } + + ChunkState::Ok(self) + } +} diff --git a/src/sharry/mod.rs b/src/sharry/mod.rs index 13bd147..5da98a5 100644 --- a/src/sharry/mod.rs +++ b/src/sharry/mod.rs @@ -7,5 +7,5 @@ mod share; pub use alias::Alias; pub use api::{NewShareRequest, Uri}; -pub use file::File; +pub use file::{FileChecked, FileUploading}; pub use share::Share;