From 7ec7827cd956fa8371e12d66d32539c39374651e Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 26 Oct 2024 14:00:49 -0400 Subject: Invite accept error is Error --- src/invite/routes/invite/post.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src/invite') diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 0dd8dba..627eca3 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -36,7 +36,8 @@ pub struct Request { pub password: Password, } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] pub struct Error(pub app::AcceptError); impl IntoResponse for Error { -- cgit v1.2.3 From da485e523913df28def6335be0836b1fc437617f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 19:32:30 -0400 Subject: Restrict login names. There's no good reason to use an empty string as your login name, or to use one so long as to annoy others. Names beginning or ending with whitespace, or containing runs of whitespace, are also a technical problem, so they're also prohibited. This change does not implement [UTS #39], as I haven't yet fully understood how to do so. [UTS #39]: https://www.unicode.org/reports/tr39/ --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + docs/api/initial-setup.md | 14 ++++++++++++++ docs/api/invitations.md | 13 +++++++++++++ src/invite/app.rs | 8 +++++++- src/invite/routes/invite/post.rs | 3 +++ src/invite/routes/invite/test/post.rs | 32 ++++++++++++++++++++++++++++++++ src/login/app.rs | 12 ++++++++++-- src/login/mod.rs | 1 + src/login/validate.rs | 23 +++++++++++++++++++++++ src/setup/app.rs | 8 +++++++- src/setup/routes/post.rs | 3 +++ src/setup/routes/test.rs | 25 +++++++++++++++++++++++++ src/test/fixtures/login.rs | 6 +++++- 14 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 src/login/validate.rs (limited to 'src/invite') diff --git a/Cargo.lock b/Cargo.lock index f5ba5ff..8e10aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,6 +828,7 @@ dependencies = [ "tokio-stream", "unicode-casefold", "unicode-normalization", + "unicode-segmentation", "unix_path", "uuid", ] @@ -2142,6 +2143,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 03b6a67..630ebe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } unicode-casefold = "0.2.0" unicode-normalization = "0.1.24" +unicode-segmentation = "1.12.0" unix_path = "1.0.1" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index 306d798..b6bf270 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -51,6 +51,16 @@ The request must have the following fields: | `name` | string | The initial login's name. | | `password` | string | The initial login's password, in plain text. | + + +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +* It must be non-empty. +* It must not be "too long." (Currently, 64 characters is too long.) +* It must begin with an alphanumeric character. +* It must end with an alphanumeric character. +* It must not contain runs of multiple whitespace characters. + ### Success @@ -79,6 +89,10 @@ The response will include a `Set-Cookie` header for the `identity` cookie, provi The cookie will expire if it is not used regularly. +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + ### Setup previously completed Once completed, this operation cannot be performed a second time. Subsequent requests to this endpoint will respond with a status of `409 Conflict`. diff --git a/docs/api/invitations.md b/docs/api/invitations.md index ddbef8a..83e5145 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -130,6 +130,15 @@ The request must have the following fields: | `name` | string | The new login's name. | | `password` | string | The new login's password, in plain text. | + +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +* It must be non-empty. +* It must not be "too long." (Currently, 64 characters is too long.) +* It must begin with an alphanumeric character. +* It must end with an alphanumeric character. +* It must not contain runs of multiple whitespace characters. + ### Success @@ -162,6 +171,10 @@ The cookie will expire if it is not used regularly. This endpoint will respond with a status of `404 Not Found` when the invitation ID either does not exist, or has already been accepted. +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + ### Name in use This endpoint will respond with a status of `409 Conflict` if the requested login name has already been taken. diff --git a/src/invite/app.rs b/src/invite/app.rs index 176075f..182eb67 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -6,7 +6,7 @@ use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, validate, Login, Password}, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,6 +44,10 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { + if !validate::name(name) { + return Err(AcceptError::InvalidName(name.clone())); + } + let mut tx = self.db.begin().await?; let invite = tx .invites() @@ -92,6 +96,8 @@ impl<'a> Invites<'a> { pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), + #[error("invalid login name: {0}")] + InvalidName(Name), #[error("name in use: {0}")] DuplicateLogin(Name), #[error(transparent)] diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 627eca3..bb68e07 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -45,6 +45,9 @@ impl IntoResponse for Error { let Self(error) = self; match error { app::AcceptError::NotFound(_) => NotFound(error).into_response(), + app::AcceptError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } app::AcceptError::DuplicateLogin(_) => { (StatusCode::CONFLICT, error.to_string()).into_response() } diff --git a/src/invite/routes/invite/test/post.rs b/src/invite/routes/invite/test/post.rs index 65ab61e..40e0580 100644 --- a/src/invite/routes/invite/test/post.rs +++ b/src/invite/routes/invite/test/post.rs @@ -206,3 +206,35 @@ async fn conflicting_name() { matches!(error, AcceptError::DuplicateLogin(error_name) if error_name == conflicting_name) ); } + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::login::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::login::propose_invalid_name(); + let password = fixtures::login::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = post::Request { + name: name.clone(), + password: password.clone(), + }; + let post::Error(error) = post::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect_err("using an invalid name should fail"); + + // Verify the response + + assert!(matches!(error, AcceptError::InvalidName(error_name) if name == error_name)); +} diff --git a/src/login/app.rs b/src/login/app.rs index 2f5896f..c1bfe6e 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -3,7 +3,7 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; #[cfg(test)] -use super::{Login, Password}; +use super::{validate, Login, Password}; #[cfg(test)] use crate::{ clock::DateTime, @@ -35,6 +35,10 @@ impl<'a> Logins<'a> { password: &Password, created_at: &DateTime, ) -> Result { + if !validate::name(name) { + return Err(CreateError::InvalidName(name.clone())); + } + let password_hash = password.hash()?; let mut tx = self.db.begin().await?; @@ -57,9 +61,13 @@ impl<'a> Logins<'a> { } } +#[cfg(test)] #[derive(Debug, thiserror::Error)] -#[error(transparent)] pub enum CreateError { + #[error("invalid login name: {0}")] + InvalidName(Name), + #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] PasswordHash(#[from] password_hash::Error), } diff --git a/src/login/mod.rs b/src/login/mod.rs index 279e9a6..6d10e17 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -6,6 +6,7 @@ pub mod password; pub mod repo; mod routes; mod snapshot; +pub mod validate; pub use self::{ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, diff --git a/src/login/validate.rs b/src/login/validate.rs new file mode 100644 index 0000000..ed3eff8 --- /dev/null +++ b/src/login/validate.rs @@ -0,0 +1,23 @@ +use unicode_segmentation::UnicodeSegmentation as _; + +use crate::name::Name; + +// Picked out of a hat. The power of two is not meaningful. +const NAME_TOO_LONG: usize = 64; + +pub fn name(name: &Name) -> bool { + let display = name.display(); + + [ + display.graphemes(true).count() < NAME_TOO_LONG, + display.chars().all(|ch| !ch.is_control()), + display.chars().next().is_some_and(char::is_alphanumeric), + display.chars().last().is_some_and(char::is_alphanumeric), + display + .chars() + .zip(display.chars().skip(1)) + .all(|(a, b)| !(a.is_whitespace() && b.is_whitespace())), + ] + .into_iter() + .all(|value| value) +} diff --git a/src/setup/app.rs b/src/setup/app.rs index 030b5f6..cab7c4b 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -4,7 +4,7 @@ use super::repo::Provider as _; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, validate, Login, Password}, name::Name, token::{repo::Provider as _, Secret}, }; @@ -25,6 +25,10 @@ impl<'a> Setup<'a> { password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { + if !validate::name(name) { + return Err(Error::InvalidName(name.clone())); + } + let password_hash = password.hash()?; let mut tx = self.db.begin().await?; @@ -56,6 +60,8 @@ impl<'a> Setup<'a> { pub enum Error { #[error("initial setup previously completed")] SetupCompleted, + #[error("invalid login name: {0}")] + InvalidName(Name), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index f7b256e..2a46b04 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -42,6 +42,9 @@ impl IntoResponse for Error { fn into_response(self) -> Response { let Self(error) = self; match error { + app::Error::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), other => Internal::from(other).into_response(), } diff --git a/src/setup/routes/test.rs b/src/setup/routes/test.rs index f7562ae..5794b78 100644 --- a/src/setup/routes/test.rs +++ b/src/setup/routes/test.rs @@ -67,3 +67,28 @@ async fn login_exists() { assert!(matches!(error, app::Error::SetupCompleted)); } + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let name = fixtures::login::propose_invalid_name(); + let password = fixtures::login::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = post::Request { + name: name.clone(), + password: password.clone(), + }; + let post::Error(error) = + post::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + .await + .expect_err("setup with an invalid name fails"); + + // Verify the response + + assert!(matches!(error, app::Error::InvalidName(error_name) if name == error_name)); +} diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index e308289..86e3e39 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -1,4 +1,4 @@ -use faker_rand::en_us::internet; +use faker_rand::{en_us::internet, lorem::Paragraphs}; use uuid::Uuid; use crate::{ @@ -38,6 +38,10 @@ pub fn propose() -> (Name, Password) { (propose_name(), propose_password()) } +pub fn propose_invalid_name() -> Name { + rand::random::().to_string().into() +} + fn propose_name() -> Name { rand::random::().to_string().into() } -- cgit v1.2.3 From 9ae0faf4f027caaaf3bc4a42738d4ed31e67852d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:26:47 -0400 Subject: Create a dedicated workflow type for creating logins. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nasty design corner. Logins need to be created in three places: 1. In tests, using app.logins().create(…); 2. On initial setup, using app.setup().initial(…); and 3. When accepting invites, using app.invites().accept(…). These three places do the same thing with respect to logins, but also do a varying mix of other things. Testing is the simplest and _only_ creates a login. Initial setup and invite acceptance both issue a token for the newly-created login. Accepting an invite also invalidates the invite. Previously, those three functions have been copy-pasted variations on a theme. Now that we have validation, the copy-paste approach is no longer tenable; it will become increasingly hard to ensure that the three functions (plus any future functions) remain in synch. To accommodate the variations while consolidating login creation, I've added a typestate-based state machine, which is driven by method calls: * A creation attempt begins with `let create = Create::begin()`. This always succeeds; it packages up arguments used in later steps, but does nothing else. * A creation attempt can be validated using `let validated = create.validate()?`. This may fail. Input validation and password hashing are carried out at this stage, making it potentially expensive. * A validated attempt can be stored in the DB, using `let stored = validated.store(&mut tx).await?`. This may fail. The login will be written to the DB; the caller is responsible for transaction demarcation, to allow other things to take place in the same transaction. * A fully-stored attempt can be used to publish events, using `let login = stored.publish(self.events)`. This always succeeds, and unwraps the state machine to its final product (a `login::History`). --- src/invite/app.rs | 33 +++++++++++-------- src/login/app.rs | 38 +++++++++++---------- src/login/create.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/login/mod.rs | 3 +- src/setup/app.rs | 32 +++++++++++------- 5 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 src/login/create.rs (limited to 'src/invite') diff --git a/src/invite/app.rs b/src/invite/app.rs index 182eb67..d4e877a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,8 +5,11 @@ use super::{repo::Provider as _, Id, Invite, Summary}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,9 +47,7 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { - if !validate::name(name) { - return Err(AcceptError::InvalidName(name.clone())); - } + let create = Create::begin(name, password, accepted_at); let mut tx = self.db.begin().await?; let invite = tx @@ -59,23 +60,20 @@ impl<'a> Invites<'a> { // the invite. Final validation is in the next tx. tx.commit().await?; - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; // If the invite has been deleted or accepted in the interim, this step will // catch it. tx.invites().accept(&invite).await?; - let created = tx.sequence().next(accepted_at).await?; - let login = tx - .logins() - .create(name, &password_hash, &created) + let stored = validated + .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(&login, accepted_at).await?; + let secret = tx.tokens().issue(stored.login(), accepted_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -105,3 +103,12 @@ pub enum AcceptError { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for AcceptError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} diff --git a/src/login/app.rs b/src/login/app.rs index c1bfe6e..6da26e9 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -3,13 +3,12 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; #[cfg(test)] -use super::{validate, Login, Password}; -#[cfg(test)] -use crate::{ - clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - name::Name, +use super::{ + create::{self, Create}, + Login, Password, }; +#[cfg(test)] +use crate::{clock::DateTime, event::Broadcaster, name::Name}; pub struct Logins<'a> { db: &'a SqlitePool, @@ -35,19 +34,14 @@ impl<'a> Logins<'a> { password: &Password, created_at: &DateTime, ) -> Result { - if !validate::name(name) { - return Err(CreateError::InvalidName(name.clone())); - } - - let password_hash = password.hash()?; + let create = Create::begin(name, password, created_at); + let validated = create.validate()?; let mut tx = self.db.begin().await?; - let created = tx.sequence().next(created_at).await?; - let login = tx.logins().create(name, &password_hash, &created).await?; + let stored = validated.store(&mut tx).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok(login.as_created()) } @@ -67,7 +61,17 @@ pub enum CreateError { #[error("invalid login name: {0}")] InvalidName(Name), #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] PasswordHash(#[from] password_hash::Error), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[cfg(test)] +impl From for CreateError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } } diff --git a/src/login/create.rs b/src/login/create.rs new file mode 100644 index 0000000..693daaf --- /dev/null +++ b/src/login/create.rs @@ -0,0 +1,95 @@ +use sqlx::{sqlite::Sqlite, Transaction}; + +use super::{password::StoredHash, repo::Provider as _, validate, History, Password}; +use crate::{ + clock::DateTime, + event::{repo::Provider as _, Broadcaster, Event}, + name::Name, +}; + +pub struct Create<'a> { + name: &'a Name, + password: &'a Password, + created_at: &'a DateTime, +} + +impl<'a> Create<'a> { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn begin(name: &'a Name, password: &'a Password, created_at: &'a DateTime) -> Self { + Self { + name, + password, + created_at, + } + } + + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn validate(self) -> Result, Error> { + let Self { + name, + password, + created_at, + } = self; + + if !validate::name(name) { + return Err(Error::InvalidName(name.clone())); + } + + let password_hash = password.hash()?; + + Ok(Validated { + name, + password_hash, + created_at, + }) + } +} + +pub struct Validated<'a> { + name: &'a Name, + password_hash: StoredHash, + created_at: &'a DateTime, +} + +impl<'a> Validated<'a> { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub async fn store<'c>(self, tx: &mut Transaction<'c, Sqlite>) -> Result { + let Self { + name, + password_hash, + created_at, + } = self; + + let created = tx.sequence().next(created_at).await?; + let login = tx.logins().create(name, &password_hash, &created).await?; + + Ok(Stored { login }) + } +} + +pub struct Stored { + login: History, +} + +impl Stored { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn publish(self, events: &Broadcaster) -> History { + let Self { login } = self; + + events.broadcast(login.events().map(Event::from).collect::>()); + + login + } + + pub fn login(&self) -> &History { + &self.login + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid login name: {0}")] + InvalidName(Name), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/login/mod.rs b/src/login/mod.rs index 6d10e17..5a6d715 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod create; pub mod event; mod history; mod id; @@ -6,7 +7,7 @@ pub mod password; pub mod repo; mod routes; mod snapshot; -pub mod validate; +mod validate; pub use self::{ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, diff --git a/src/setup/app.rs b/src/setup/app.rs index cab7c4b..c1f7b69 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -3,8 +3,11 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; use crate::{ clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -25,24 +28,20 @@ impl<'a> Setup<'a> { password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { - if !validate::name(name) { - return Err(Error::InvalidName(name.clone())); - } + let create = Create::begin(name, password, created_at); - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; - let login = if tx.setup().completed().await? { + let stored = if tx.setup().completed().await? { Err(Error::SetupCompleted)? } else { - let created = tx.sequence().next(created_at).await?; - tx.logins().create(name, &password_hash, &created).await? + validated.store(&mut tx).await? }; - let secret = tx.tokens().issue(&login, created_at).await?; + let secret = tx.tokens().issue(stored.login(), created_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -67,3 +66,12 @@ pub enum Error { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for Error { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} -- cgit v1.2.3