Compare commits

..

11 commits

14 changed files with 305 additions and 200 deletions

42
.vscode/tasks.json vendored
View file

@ -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"
}
],
}

View file

@ -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<String> {
fn new_share(args: &Cli) -> crate::Result<ShareID> {
new_http(args).share_create(&args.get_uri(), &args.alias, args.get_share_request())
}

View file

@ -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<file::Uploading>,
files: VecDeque<file::Checked>,
@ -97,7 +97,7 @@ impl CacheFile {
pub fn from_args(
args: &Cli,
new_share: impl FnOnce(&Cli) -> crate::Result<String>,
new_share: impl FnOnce(&Cli) -> crate::Result<ShareID>,
) -> crate::Result<Self> {
let mut files = args.files.clone();

View file

@ -11,7 +11,7 @@ use log::LevelFilter;
use crate::{
file::{Checked, FileTrait},
sharry::{NewShareRequest, Uri},
sharry::{AliasID, Uri, json::NewShareRequest},
};
#[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());

View file

@ -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 {

View file

@ -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<Uploading> {
let file_id = client.file_create(uri, alias_id, share_id, &self)?;

View file

@ -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
}

View file

@ -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<String>,
file_id: String,
file_id: sharry::FileID,
#[serde(skip)]
last_offset: Option<u64>,
offset: u64,
}
impl Uploading {
pub(super) fn new(path: PathBuf, size: u64, hash: Option<String>, file_id: String) -> Self {
pub(super) fn new(
path: PathBuf,
size: u64,
hash: Option<String>,
file_id: sharry::FileID,
) -> Self {
Self {
path,
size,

View file

@ -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,
data: sharry::NewShareRequest,
) -> crate::Result<String> {
alias_id: &AliasID,
data: sharry::json::NewShareRequest,
) -> crate::Result<ShareID> {
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))?;
@ -65,7 +65,7 @@ impl sharry::Client for ureq::Agent {
crate::Error::res_status_check(res.status(), ureq::http::StatusCode::OK)?;
res.body_mut()
.read_json::<sharry::NewShareResponse>()
.read_json::<sharry::json::NewShareResponse>()
.map_err(crate::Error::response)?
};
@ -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))?;
@ -94,7 +94,7 @@ impl sharry::Client for ureq::Agent {
crate::Error::res_status_check(res.status(), ureq::http::StatusCode::OK)?;
res.body_mut()
.read_json::<sharry::NotifyShareResponse>()
.read_json::<sharry::json::NotifyShareResponse>()
.map_err(crate::Error::response)?
};
@ -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<String> {
) -> crate::Result<FileID> {
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(

View file

@ -1,87 +0,0 @@
use std::sync::LazyLock;
use log::trace;
use regex::Regex;
use crate::file;
use super::api::{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<fid>[^/]+)` capture FID (one or more non-slash chars)
/// - `$` end of string
static UPLOAD_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
trace!("compiling UPLOAD_URL_RE");
Regex::new(
r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P<fid>[^/]+)$",
)
.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(
"<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
uri,
))
}
}
fn share_create(
&self,
uri: &Uri,
alias_id: &str,
data: NewShareRequest,
) -> crate::Result<String>;
fn share_notify(&self, uri: &Uri, alias_id: &str, share_id: &str) -> crate::Result<()>;
fn file_create(
&self,
uri: &Uri,
alias_id: &str,
share_id: &str,
file: &file::Checked,
) -> crate::Result<String>;
fn file_patch(
&self,
uri: &Uri,
alias_id: &str,
share_id: &str,
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());
// }
// }

155
src/sharry/ids.rs Normal file
View file

@ -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<String> 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<String> 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<String> for FileID {
type Error = crate::Error;
fn try_from(value: String) -> crate::Result<Self> {
/// 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<fid>[^/]+)` capture FID (one or more non-slash chars)
/// - `$` end of string
static UPLOAD_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
trace!("compiling UPLOAD_URL_RE");
Regex::new(
r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P<fid>[^/]+)$",
)
.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(
"<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
value,
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_urls_produce_expected_file_id() {
// a handful of validlooking 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, "<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:?}"),
}
}
}
}

41
src/sharry/json.rs Normal file
View file

@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Debug)]
#[allow(non_snake_case)]
pub struct NewShareRequest {
name: String,
validity: u32,
description: Option<String>,
maxViews: u32,
password: Option<String>,
}
impl NewShareRequest {
pub fn new(
name: impl Into<String>,
description: Option<impl Into<String>>,
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,
}

View file

@ -1,5 +1,35 @@
mod api;
mod client;
mod ids;
pub mod json;
mod uri;
pub use api::{NewShareRequest, NewShareResponse, NotifyShareResponse, Uri};
pub use client::Client;
pub use ids::{AliasID, FileID, ShareID};
pub use uri::Uri;
use crate::file;
pub trait Client {
fn share_create(
&self,
uri: &Uri,
alias_id: &AliasID,
data: json::NewShareRequest,
) -> crate::Result<ShareID>;
fn share_notify(&self, uri: &Uri, alias_id: &AliasID, share_id: &ShareID) -> crate::Result<()>;
fn file_create(
&self,
uri: &Uri,
alias_id: &AliasID,
share_id: &ShareID,
file: &file::Checked,
) -> crate::Result<FileID>;
fn file_patch(
&self,
uri: &Uri,
alias_id: &AliasID,
share_id: &ShareID,
chunk: &file::Chunk,
) -> crate::Result<()>;
}

View file

@ -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<String>,
maxViews: u32,
password: Option<String>,
}
impl NewShareRequest {
pub fn new(
name: impl Into<String>,
description: Option<impl Into<String>>,
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,
}