diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ceeafb4..574737d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,16 +43,16 @@ "problemMatcher": "$rustc", "group": "build" }, - // { - // "label": "Run Unit Tests", - // "type": "cargo", - // "command": "test", - // "args": [ - // "--lib" - // ], - // "problemMatcher": "$rustc", - // "group": "test" - // }, + { + "label": "Run Unit Tests", + "type": "cargo", + "command": "test", + "args": [ + "--lib" + ], + "problemMatcher": "$rustc", + "group": "test" + }, // { // "label": "Run Integration Tests", // "type": "cargo", @@ -64,16 +64,16 @@ // "problemMatcher": "$rustc", // "group": "test" // }, - // { - // "label": "Run All Tests", - // "type": "shell", - // "command": "echo All Tests successful!", - // "dependsOn": [ - // "Run Unit Tests", - // "Run Integration Tests" - // ], - // "dependsOrder": "sequence", - // "group": "test" - // } + { + "label": "Run All Tests", + "type": "shell", + "command": "echo All Tests successful!", + "dependsOn": [ + "Run Unit Tests", + "Run Integration Tests" + ], + "dependsOrder": "sequence", + "group": "test" + } ], } \ No newline at end of file diff --git a/src/appstate.rs b/src/appstate.rs index 6beba81..99ea3d4 100644 --- a/src/appstate.rs +++ b/src/appstate.rs @@ -8,7 +8,7 @@ use crate::{ cli::Cli, file::{Chunk, FileTrait}, output::new_progressbar, - sharry::Client, + sharry::{Client, ShareID}, }; pub struct AppState { @@ -32,7 +32,7 @@ fn new_http(args: &Cli) -> ureq::Agent { .into() } -fn new_share(args: &Cli) -> crate::Result { +fn new_share(args: &Cli) -> crate::Result { new_http(args).share_create(&args.get_uri(), &args.alias, args.get_share_request()) } diff --git a/src/cachefile.rs b/src/cachefile.rs index 487fce7..15b18e9 100644 --- a/src/cachefile.rs +++ b/src/cachefile.rs @@ -14,7 +14,7 @@ use crate::{ cli::Cli, file::{self, Chunk, FileTrait}, output::new_progressbar, - sharry::{Client, Uri}, + sharry::{AliasID, Client, ShareID, Uri}, }; #[derive(Serialize, Deserialize, Debug)] @@ -23,8 +23,8 @@ pub struct CacheFile { file_name: PathBuf, uri: Uri, - alias_id: String, - share_id: String, + alias_id: AliasID, + share_id: ShareID, uploading: Option, files: VecDeque, @@ -97,7 +97,7 @@ impl CacheFile { pub fn from_args( args: &Cli, - new_share: impl FnOnce(&Cli) -> crate::Result, + new_share: impl FnOnce(&Cli) -> crate::Result, ) -> crate::Result { let mut files = args.files.clone(); diff --git a/src/cli.rs b/src/cli.rs index 491be5d..5b5f3c8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ use log::LevelFilter; use crate::{ file::{Checked, FileTrait}, - sharry::{NewShareRequest, Uri}, + sharry::{AliasID, NewShareRequest, Uri}, }; #[derive(Parser)] @@ -69,7 +69,7 @@ pub struct Cli { url: String, /// ID of a public alias to use - pub alias: String, + pub alias: AliasID, /// Files to upload to the new share #[arg(value_name = "FILE", required = true, value_parser = parse_sharry_file)] @@ -159,7 +159,7 @@ impl Cli { let mut hasher = Blake2b::new().hash_length(16).to_state(); hasher.update(self.get_uri().as_ref()); - hasher.update(self.alias.as_bytes()); + hasher.update(self.alias.as_ref()); for chk in sorted(&self.files) { hasher.update(chk.as_ref()); diff --git a/src/error.rs b/src/error.rs index 5541824..e674931 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,18 +1,20 @@ use std::fmt; +use crate::sharry; + #[derive(Debug, thiserror::Error)] pub enum Parameter { #[error("given URI {0:?}")] Uri(String), #[error("given Alias ID {0:?}")] - AliasID(String), + AliasID(sharry::AliasID), #[error("stored Share ID {0:?}")] - ShareID(String), + ShareID(sharry::ShareID), - #[error("stored File ID {0:?}")] - FileID(String), + #[error("stored {0:?}")] + FileID(sharry::FileID), } impl Parameter { diff --git a/src/file/checked.rs b/src/file/checked.rs index 80331d0..fa54124 100644 --- a/src/file/checked.rs +++ b/src/file/checked.rs @@ -76,8 +76,8 @@ impl Checked { self, client: &impl sharry::Client, uri: &sharry::Uri, - alias_id: &str, - share_id: &str, + alias_id: &sharry::AliasID, + share_id: &sharry::ShareID, ) -> crate::Result { let file_id = client.file_create(uri, alias_id, share_id, &self)?; diff --git a/src/file/chunk.rs b/src/file/chunk.rs index da46daf..809482e 100644 --- a/src/file/chunk.rs +++ b/src/file/chunk.rs @@ -1,7 +1,9 @@ use std::fmt; +use crate::sharry; + pub struct Chunk<'t> { - file_id: String, + file_id: sharry::FileID, offset: u64, data: &'t [u8], } @@ -17,7 +19,7 @@ impl fmt::Debug for Chunk<'_> { } impl<'t> Chunk<'t> { - pub fn new(file_id: String, offset: u64, data: &'t [u8]) -> Self { + pub fn new(file_id: sharry::FileID, offset: u64, data: &'t [u8]) -> Self { Self { file_id, offset, @@ -25,7 +27,7 @@ impl<'t> Chunk<'t> { } } - pub fn get_file_id(&self) -> &str { + pub fn get_file_id(&self) -> &sharry::FileID { &self.file_id } diff --git a/src/file/uploading.rs b/src/file/uploading.rs index 4264148..290f494 100644 --- a/src/file/uploading.rs +++ b/src/file/uploading.rs @@ -7,6 +7,7 @@ use std::{ use log::warn; use serde::{Deserialize, Serialize}; +use crate::sharry; use super::{Checked, Chunk, FileTrait}; @@ -18,14 +19,19 @@ pub struct Uploading { size: u64, /// hash of that file hash: Option, - file_id: String, + file_id: sharry::FileID, #[serde(skip)] last_offset: Option, offset: u64, } impl Uploading { - pub(super) fn new(path: PathBuf, size: u64, hash: Option, file_id: String) -> Self { + pub(super) fn new( + path: PathBuf, + size: u64, + hash: Option, + file_id: sharry::FileID, + ) -> Self { Self { path, size, diff --git a/src/impl_ureq.rs b/src/impl_ureq.rs index 3b27e4d..ff07e48 100644 --- a/src/impl_ureq.rs +++ b/src/impl_ureq.rs @@ -2,14 +2,14 @@ use log::{debug, trace}; use crate::{ file::{self, FileTrait}, - sharry::{self, Uri}, + sharry::{self, AliasID, FileID, ShareID, Uri}, }; fn find_cause( uri: &Uri, - alias_id: &str, - share_id: Option<&str>, - file_id: Option<&str>, + alias_id: &AliasID, + share_id: Option<&ShareID>, + file_id: Option<&FileID>, ) -> impl FnOnce(ureq::Error) -> crate::Error { move |error| match error { ureq::Error::StatusCode(403) => { @@ -49,15 +49,15 @@ impl sharry::Client for ureq::Agent { fn share_create( &self, uri: &Uri, - alias_id: &str, + alias_id: &AliasID, data: sharry::NewShareRequest, - ) -> crate::Result { + ) -> crate::Result { let res = { let endpoint = uri.share_create(); let mut res = self .post(&endpoint) - .header("Sharry-Alias", alias_id) + .header("Sharry-Alias", alias_id.as_ref()) .send_json(data) .map_err(find_cause(uri, alias_id, None, None))?; @@ -74,19 +74,19 @@ impl sharry::Client for ureq::Agent { if res.success && (res.message == "Share created.") { trace!("new share id: {:?}", res.id); - Ok(res.id) + Ok(res.id.into()) } else { Err(crate::Error::response(format!("{res:?}"))) } } - fn share_notify(&self, uri: &Uri, alias_id: &str, share_id: &str) -> crate::Result<()> { + fn share_notify(&self, uri: &Uri, alias_id: &AliasID, share_id: &ShareID) -> crate::Result<()> { let res = { let endpoint = uri.share_notify(share_id); let mut res = self .post(&endpoint) - .header("Sharry-Alias", alias_id) + .header("Sharry-Alias", alias_id.as_ref()) .send_empty() .map_err(find_cause(uri, alias_id, Some(share_id), None))?; @@ -106,16 +106,16 @@ impl sharry::Client for ureq::Agent { fn file_create( &self, uri: &Uri, - alias_id: &str, - share_id: &str, + alias_id: &AliasID, + share_id: &ShareID, file: &file::Checked, - ) -> crate::Result { + ) -> crate::Result { let res = { let endpoint = uri.file_create(share_id); let res = self .post(&endpoint) - .header("Sharry-Alias", alias_id) + .header("Sharry-Alias", alias_id.as_ref()) .header("Sharry-File-Name", file.get_name()) .header("Upload-Length", file.get_size()) .send_empty() @@ -132,18 +132,14 @@ impl sharry::Client for ureq::Agent { .map_err(crate::Error::response)? .to_string(); - let file_id = Self::get_file_id(&location)?; - - debug!("location: {location:?}, file_id: {file_id:?}"); - - Ok(file_id.to_owned()) + FileID::try_from(location) } fn file_patch( &self, uri: &Uri, - alias_id: &str, - share_id: &str, + alias_id: &AliasID, + share_id: &ShareID, chunk: &file::Chunk, ) -> crate::Result<()> { let res = { @@ -151,7 +147,7 @@ impl sharry::Client for ureq::Agent { let res = self .patch(&endpoint) - .header("Sharry-Alias", alias_id) + .header("Sharry-Alias", alias_id.as_ref()) .header("Upload-Offset", chunk.get_offset()) .send(chunk.get_data()) .map_err(find_cause( diff --git a/src/sharry/api/ids.rs b/src/sharry/api/ids.rs new file mode 100644 index 0000000..6f9121d --- /dev/null +++ b/src/sharry/api/ids.rs @@ -0,0 +1,155 @@ +use std::{fmt, sync::LazyLock}; + +use log::{debug, trace}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AliasID(String); + +impl fmt::Display for AliasID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef<[u8]> for AliasID { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl From for AliasID { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ShareID(String); + +impl fmt::Display for ShareID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From for ShareID { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileID(String); + +impl fmt::Display for FileID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for FileID { + type Error = crate::Error; + + fn try_from(value: String) -> crate::Result { + /// Pattern breakdown: + /// - `^([^:/?#]+)://` – scheme (anything but `:/?#`) + `"://"` + /// - `([^/?#]+)` – authority/host (anything but `/?#`) + /// - `/api/v2/alias/upload/` – literal path segment + /// - `([^/]+)` – capture SID (one or more non-slash chars) + /// - `/files/tus/` – literal path segment + /// - `(?P[^/]+)` – capture FID (one or more non-slash chars) + /// - `$` – end of string + static UPLOAD_URL_RE: LazyLock = LazyLock::new(|| { + trace!("compiling UPLOAD_URL_RE"); + + Regex::new( + r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P[^/]+)$", + ) + .expect("Regex compilation failed") + }); + + trace!("TryFrom {value:?}"); + + if let Some(fid) = UPLOAD_URL_RE + .captures(&value) + .and_then(|caps| caps.name("fid").map(|m| m.as_str())) + { + let result = Self(fid.to_owned()); + debug!("{result:?}"); + + Ok(result) + } else { + Err(crate::Error::mismatch( + ":///api/v2/alias/upload//files/tus/", + value, + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_urls_produce_expected_file_id() { + // a handful of valid‐looking URLs + let cases = vec![ + ( + "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", + ), + ]; + + for (good, expected_fid) in cases { + let s = good.to_string(); + let file_id = FileID::try_from(s.clone()).expect("URL should parse successfully"); + assert_eq!( + 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 { + crate::Error::Mismatch { expected, actual } => { + assert_eq!( + expected, ":///api/v2/alias/upload//files/tus/", + "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:?}"), + } + } + } +} diff --git a/src/sharry/api/mod.rs b/src/sharry/api/mod.rs new file mode 100644 index 0000000..5c084ea --- /dev/null +++ b/src/sharry/api/mod.rs @@ -0,0 +1,47 @@ +mod ids; +mod uri; + +use serde::{Deserialize, Serialize}; + +pub use ids::{AliasID, FileID, ShareID}; +pub use uri::Uri; + +#[derive(Serialize, Debug)] +#[allow(non_snake_case)] +pub struct NewShareRequest { + name: String, + validity: u32, + description: Option, + maxViews: u32, + password: Option, +} + +impl NewShareRequest { + pub fn new( + name: impl Into, + description: Option>, + max_views: u32, + ) -> Self { + Self { + name: name.into(), + validity: 0, + description: description.map(Into::into), + maxViews: max_views, + password: None, + } + } +} + +#[derive(Deserialize, Debug)] +pub struct NewShareResponse { + pub success: bool, + pub message: String, + pub id: String, +} + +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct NotifyShareResponse { + pub success: bool, + pub message: String, +} diff --git a/src/sharry/api.rs b/src/sharry/api/uri.rs similarity index 50% rename from src/sharry/api.rs rename to src/sharry/api/uri.rs index c34ab31..ea3e647 100644 --- a/src/sharry/api.rs +++ b/src/sharry/api/uri.rs @@ -33,55 +33,15 @@ impl Uri { self.endpoint(format_args!("alias/upload/new")) } - pub fn share_notify(&self, share_id: &str) -> String { + pub fn share_notify(&self, share_id: &super::ShareID) -> String { self.endpoint(format_args!("alias/mail/notify/{share_id}")) } - pub fn file_create(&self, share_id: &str) -> String { + pub fn file_create(&self, share_id: &super::ShareID) -> String { self.endpoint(format_args!("alias/upload/{share_id}/files/tus")) } - pub fn file_patch(&self, share_id: &str, file_id: &str) -> String { + pub fn file_patch(&self, share_id: &super::ShareID, file_id: &super::FileID) -> String { self.endpoint(format_args!("alias/upload/{share_id}/files/tus/{file_id}")) } } - -#[derive(Serialize, Debug)] -#[allow(non_snake_case)] -pub struct NewShareRequest { - name: String, - validity: u32, - description: Option, - maxViews: u32, - password: Option, -} - -impl NewShareRequest { - pub fn new( - name: impl Into, - description: Option>, - max_views: u32, - ) -> Self { - Self { - name: name.into(), - validity: 0, - description: description.map(Into::into), - maxViews: max_views, - password: None, - } - } -} - -#[derive(Deserialize, Debug)] -pub struct NewShareResponse { - pub success: bool, - pub message: String, - pub id: String, -} - -#[derive(Deserialize, Debug)] -#[allow(dead_code)] -pub struct NotifyShareResponse { - pub success: bool, - pub message: String, -} diff --git a/src/sharry/client.rs b/src/sharry/client.rs index e6092e2..9d71d43 100644 --- a/src/sharry/client.rs +++ b/src/sharry/client.rs @@ -1,87 +1,33 @@ -use std::sync::LazyLock; - -use log::trace; -use regex::Regex; - use crate::file; -use super::api::{NewShareRequest, Uri}; +use super::{ + AliasID, ShareID, + api::{FileID, NewShareRequest, Uri}, +}; pub trait Client { - fn get_file_id(uri: &str) -> crate::Result<&str> { - /// Pattern breakdown: - /// - `^([^:/?#]+)://` – scheme (anything but `:/?#`) + `"://"` - /// - `([^/?#]+)` – authority/host (anything but `/?#`) - /// - `/api/v2/alias/upload/` – literal path segment - /// - `([^/]+)` – capture SID (one or more non-slash chars) - /// - `/files/tus/` – literal path segment - /// - `(?P[^/]+)` – capture FID (one or more non-slash chars) - /// - `$` – end of string - static UPLOAD_URL_RE: LazyLock = LazyLock::new(|| { - trace!("compiling UPLOAD_URL_RE"); - - Regex::new( - r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P[^/]+)$", - ) - .expect("Regex compilation failed") - }); - - if let Some(fid) = UPLOAD_URL_RE - .captures(uri) - .and_then(|caps| caps.name("fid").map(|m| m.as_str())) - { - Ok(fid) - } else { - Err(crate::Error::mismatch( - ":///api/v2/alias/upload//files/tus/", - uri, - )) - } - } - fn share_create( &self, uri: &Uri, - alias_id: &str, + alias_id: &AliasID, data: NewShareRequest, - ) -> crate::Result; + ) -> crate::Result; - fn share_notify(&self, uri: &Uri, alias_id: &str, share_id: &str) -> crate::Result<()>; + fn share_notify(&self, uri: &Uri, alias_id: &AliasID, share_id: &ShareID) -> crate::Result<()>; fn file_create( &self, uri: &Uri, - alias_id: &str, - share_id: &str, + alias_id: &AliasID, + share_id: &ShareID, file: &file::Checked, - ) -> crate::Result; + ) -> crate::Result; fn file_patch( &self, uri: &Uri, - alias_id: &str, - share_id: &str, + alias_id: &AliasID, + share_id: &ShareID, chunk: &file::Chunk, ) -> crate::Result<()>; } - -// TODO move into tests subdir - -// #[cfg(test)] -// mod tests { -// use super::*; - -// #[test] -// fn test_get_file_id() { -// let good = "https://example.com/api/v2/alias/upload/SID123/files/tus/FID456"; -// let good = Client::get_file_id(good); -// assert!(good.is_ok()); -// assert_eq!(good.unwrap(), "FID456"); - -// let bad = "https://example.com/api/v2/alias/upload//files/tus/FID456"; // missing SID -// assert!(Client::get_file_id(bad).is_err()); - -// let bad: &'static str = "https://example.com/api/v2/alias/upload/SID123/files/tus/"; // missing FID -// assert!(Client::get_file_id(bad).is_err()); -// } -// } diff --git a/src/sharry/mod.rs b/src/sharry/mod.rs index 5da85d2..8b53aca 100644 --- a/src/sharry/mod.rs +++ b/src/sharry/mod.rs @@ -1,5 +1,7 @@ mod api; mod client; -pub use api::{NewShareRequest, NewShareResponse, NotifyShareResponse, Uri}; +pub use api::{ + AliasID, FileID, NewShareRequest, NewShareResponse, NotifyShareResponse, ShareID, Uri, +}; pub use client::Client;