From 3c17f7e2fc3831a12af3215994f3c5cdc75901c3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:32:05 +0000 Subject: [PATCH 1/4] feat(rs-client): Make Usecase::from_static a const fn objectstore-types' Usecase now stores either a &'static str or an Arc and gains a const fn from_static, with validation kept private. The client forwards to it, so an invalid name in a const or static fails compilation. Co-Authored-By: Lorenzo Cian <17258265+lcian@users.noreply.github.com> --- clients/rust/README.md | 4 +- clients/rust/src/client.rs | 23 ++++- objectstore-types/src/usecase.rs | 162 ++++++++++++++++++++++++++++--- 3 files changed, 168 insertions(+), 21 deletions(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index 99b4987e..bd6c855e 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -331,9 +331,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { .expect("Objectstore client to build successfully") }); -static ATTACHMENTS: LazyLock = LazyLock::new(|| { - Usecase::from_static("attachments") -}); +static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); async fn example() -> Result<()> { let session = CLIENT.session(ATTACHMENTS.for_project(42, 1337))?; diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index b66cdaf0..2d414b24 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -158,7 +158,7 @@ impl ClientBuilder { /// normalized. #[derive(Debug, Clone)] pub struct Usecase { - name: Arc, + name: UsecaseName, compression: Option, expiration_policy: ExpirationPolicy, } @@ -174,7 +174,7 @@ impl Usecase { /// [`Usecase`]. pub fn new(name: &str) -> Result { Ok(Self { - name: Arc::new(UsecaseName::new(name)?), + name: UsecaseName::new(name)?, compression: Some(Compression::Zstd), expiration_policy: ExpirationPolicy::default(), }) @@ -186,9 +186,14 @@ impl Usecase { /// /// # Panics /// - /// Panics if the name violates the naming rules on [`Usecase`]. - pub fn from_static(name: &'static str) -> Self { - Self::new(name).expect("invalid usecase name") + /// Panics if the name violates the naming rules on [`Usecase`]. In a const + /// context, this panic is reported as a compile error. + pub const fn from_static(name: &'static str) -> Self { + Self { + name: UsecaseName::from_static(name), + compression: Some(Compression::Zstd), + expiration_policy: ExpirationPolicy::Manual, + } } /// Returns the name of this usecase. @@ -591,6 +596,14 @@ mod tests { )); } + #[test] + fn test_usecase_from_static_const() { + const USECASE: Usecase = Usecase::from_static("testing"); + assert_eq!(USECASE.name(), "testing"); + assert_eq!(USECASE.compression(), Some(Compression::Zstd)); + assert_eq!(USECASE.expiration_policy(), ExpirationPolicy::default()); + } + #[test] #[should_panic(expected = "invalid usecase name")] fn test_invalid_usecase_panics() { diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index 02fa5f66..92f2e59a 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -1,10 +1,13 @@ //! Validated usecase names shared by clients and the server. use std::borrow::Borrow; +use std::cmp::Ordering; use std::fmt; +use std::hash::{Hash, Hasher}; use std::str::FromStr; +use std::sync::Arc; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Minimum length of a usecase name in bytes. pub const MIN_USECASE_NAME_LENGTH: usize = 3; @@ -29,33 +32,106 @@ pub const MAX_USECASE_NAME_LENGTH: usize = 64; /// assert!(Usecase::new("testing/org=999/injected").is_err()); /// # Ok::<(), objectstore_types::usecase::InvalidUsecaseName>(()) /// ``` -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -#[serde(transparent)] -pub struct Usecase(String); +/// +/// Use [`Usecase::from_static`] for statically known names and [`Usecase::new`] +/// for dynamic names. +#[derive(Clone, Debug)] +pub struct Usecase(Name); + +/// Storage for a validated [`Usecase`] name. +#[derive(Clone, Debug)] +enum Name { + /// A name validated by [`Usecase::from_static`], stored without allocating. + Static(&'static str), + /// A name validated at runtime by [`Usecase::new`]. + Dynamic(Arc), +} impl Usecase { /// Creates a validated usecase name from a borrowed or owned string. /// + /// Use [`Self::from_static`] for statically defined names. + /// /// # Errors /// /// Returns [`InvalidUsecaseName`] if the name violates the rules on /// [`Usecase`]. pub fn new(name: impl Into) -> Result { let name = name.into(); - if !(MIN_USECASE_NAME_LENGTH..=MAX_USECASE_NAME_LENGTH).contains(&name.len()) { - return Err(InvalidUsecaseName::InvalidLength); - } - for c in name.chars() { - if !c.is_ascii_alphanumeric() && c != '_' && c != '-' { - return Err(InvalidUsecaseName::InvalidChar(c)); + validate(&name)?; + Ok(Self(Name::Dynamic(name.into()))) + } + + /// Creates a usecase name from a name known at compile time. + /// + /// Use [`Self::new`] for dynamic names. + /// + /// # Panics + /// + /// Panics if the name violates the rules on [`Usecase`]. In a const + /// context, this panic is reported as a compile error. + pub const fn from_static(name: &'static str) -> Self { + match validate(name) { + Ok(()) => Self(Name::Static(name)), + Err(InvalidUsecaseName::InvalidLength) => { + panic!("invalid usecase name: must be 3–64 ASCII characters long") + } + Err(InvalidUsecaseName::InvalidChar(_)) => { + panic!("invalid usecase name: expected only ASCII letters, digits, '_' or '-'") } } - Ok(Self(name)) } /// Returns the usecase name as a string slice. pub fn as_str(&self) -> &str { - &self.0 + match &self.0 { + Name::Static(name) => name, + Name::Dynamic(name) => name, + } + } +} + +/// Checks whether a name follows the rules on [`Usecase`]. +const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { + let bytes = name.as_bytes(); + if bytes.len() < MIN_USECASE_NAME_LENGTH || bytes.len() > MAX_USECASE_NAME_LENGTH { + return Err(InvalidUsecaseName::InvalidLength); + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_alphanumeric() && b != b'_' && b != b'-' { + // All preceding bytes are ASCII, so `i` is a char boundary. + return Err(InvalidUsecaseName::InvalidChar(decode_char(bytes, i))); + } + i += 1; + } + Ok(()) +} + +impl PartialEq for Usecase { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for Usecase {} + +impl PartialOrd for Usecase { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Usecase { + fn cmp(&self, other: &Self) -> Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl Hash for Usecase { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); } } @@ -103,7 +179,16 @@ impl TryFrom for Usecase { impl From for String { fn from(name: Usecase) -> Self { - name.0 + name.as_str().to_owned() + } +} + +impl Serialize for Usecase { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) } } @@ -116,6 +201,26 @@ impl<'de> Deserialize<'de> for Usecase { } } +/// Decodes the UTF-8 encoded char starting at byte index `i` of a valid `str`. +const fn decode_char(bytes: &[u8], i: usize) -> char { + let lead = bytes[i] as u32; + let (len, mut code) = match lead { + 0x00..0x80 => (1, lead), + 0xC0..0xE0 => (2, lead & 0x1F), + 0xE0..0xF0 => (3, lead & 0x0F), + _ => (4, lead & 0x07), + }; + let mut j = 1; + while j < len { + code = (code << 6) | (bytes[i + j] as u32 & 0x3F); + j += 1; + } + match char::from_u32(code) { + Some(c) => c, + None => char::REPLACEMENT_CHARACTER, + } +} + /// An error indicating that a usecase name is invalid. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum InvalidUsecaseName { @@ -147,14 +252,45 @@ mod tests { ("test.name", '.'), ("test name", ' '), ("café", 'é'), + ("ab€cd", '€'), + ("ab🦀cd", '🦀'), ] { assert_eq!( Usecase::new(name), Err(InvalidUsecaseName::InvalidChar(character)) ); + assert_eq!( + validate(name), + Err(InvalidUsecaseName::InvalidChar(character)) + ); } } + #[test] + fn from_static_validates_in_const_context() { + const NAME: Usecase = Usecase::from_static("debug-files"); + assert_eq!(NAME.as_str(), "debug-files"); + } + + #[test] + #[should_panic(expected = "invalid usecase name")] + fn from_static_panics_on_invalid_name() { + Usecase::from_static("testing/org=999/injected"); + } + + #[test] + fn static_and_dynamic_names_are_equal() { + use std::collections::HashSet; + + let dynamic = Usecase::new("debug-files").unwrap(); + let static_ = Usecase::from_static("debug-files"); + assert_eq!(dynamic, static_); + assert_eq!(dynamic.cmp(&static_), Ordering::Equal); + let set = HashSet::from([dynamic]); + assert!(set.contains(&static_)); + assert!(set.contains("debug-files")); + } + #[test] fn serde_validates_and_uses_strings() { let name = Usecase::new("Debug_Files-123").unwrap(); From c81143b4c698da89c376dbf6ad0598c46eca9f84 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Fri, 30 Oct 2026 13:18:33 +0100 Subject: [PATCH 2/4] ref(types)!: Report invalid usecase characters by position InvalidUsecaseName::InvalidChar now carries the byte position of the first invalid character instead of the decoded char, so const validation needs no UTF-8 decoder. Error messages live in shared constants used by both the Display impl and the const panics in from_static, which now has #[track_caller]. Usecase::new takes impl AsRef and allocates the Arc once, deserialization reads the string through a visitor instead of an intermediate String, and Debug prints the name without the internal storage variant. The client's constructors share one with_name helper, and from_static gains doc examples, including a compile_fail check. BREAKING CHANGE: InvalidUsecaseName::InvalidChar holds a usize byte position instead of a char, and Usecase::new accepts impl AsRef instead of impl Into. --- clients/rust/src/client.rs | 26 ++++-- objectstore-types/src/usecase.rs | 149 ++++++++++++++++++------------- 2 files changed, 106 insertions(+), 69 deletions(-) diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 2d414b24..832ca474 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -173,25 +173,35 @@ impl Usecase { /// Returns [`InvalidUsecaseName`] if the name violates the naming rules on /// [`Usecase`]. pub fn new(name: &str) -> Result { - Ok(Self { - name: UsecaseName::new(name)?, - compression: Some(Compression::Zstd), - expiration_policy: ExpirationPolicy::default(), - }) + Ok(Self::with_name(UsecaseName::new(name)?)) } /// Creates a Usecase for a statically defined name. /// - /// Use [`Self::new`] for dynamic names. + /// Use [`Self::new`] for dynamic names. The result can initialize a `static`: + /// + /// ``` + /// use objectstore_client::Usecase; + /// + /// static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); + /// assert_eq!(ATTACHMENTS.name(), "attachments"); + /// ``` /// /// # Panics /// /// Panics if the name violates the naming rules on [`Usecase`]. In a const /// context, this panic is reported as a compile error. + #[track_caller] pub const fn from_static(name: &'static str) -> Self { + Self::with_name(UsecaseName::from_static(name)) + } + + /// Creates a Usecase with the default compression and expiration policy. + const fn with_name(name: UsecaseName) -> Self { Self { - name: UsecaseName::from_static(name), + name, compression: Some(Compression::Zstd), + // `ExpirationPolicy::default()` isn't callable in a const fn. expiration_policy: ExpirationPolicy::Manual, } } @@ -592,7 +602,7 @@ mod tests { )); assert!(matches!( Usecase::new("testing/org=999/injected"), - Err(InvalidUsecaseName::InvalidChar('/')) + Err(InvalidUsecaseName::InvalidChar(7)) )); } diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index 92f2e59a..b45648de 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -15,6 +15,21 @@ pub const MIN_USECASE_NAME_LENGTH: usize = 3; /// Maximum length of a usecase name in bytes. pub const MAX_USECASE_NAME_LENGTH: usize = 64; +/// Message for [`InvalidUsecaseName::InvalidLength`]. +const INVALID_LENGTH: &str = "invalid usecase name: must be 3-64 ASCII characters long"; + +/// Message for [`InvalidUsecaseName::InvalidChar`], without the position. +const INVALID_CHAR: &str = "invalid usecase name: expected ASCII letters, digits, '_' or '-'"; + +/// Storage for a validated [`Usecase`] name. +#[derive(Clone)] +enum Repr { + /// A name validated by [`Usecase::from_static`], stored without allocating. + Static(&'static str), + /// A name validated at runtime by [`Usecase::new`]. + Dynamic(Arc), +} + /// A validated, case-sensitive name for an Objectstore workload. /// /// Names must be 3–64 ASCII characters from `[A-Za-z0-9_-]`. Any permitted @@ -35,17 +50,8 @@ pub const MAX_USECASE_NAME_LENGTH: usize = 64; /// /// Use [`Usecase::from_static`] for statically known names and [`Usecase::new`] /// for dynamic names. -#[derive(Clone, Debug)] -pub struct Usecase(Name); - -/// Storage for a validated [`Usecase`] name. -#[derive(Clone, Debug)] -enum Name { - /// A name validated by [`Usecase::from_static`], stored without allocating. - Static(&'static str), - /// A name validated at runtime by [`Usecase::new`]. - Dynamic(Arc), -} +#[derive(Clone)] +pub struct Usecase(Repr); impl Usecase { /// Creates a validated usecase name from a borrowed or owned string. @@ -56,37 +62,49 @@ impl Usecase { /// /// Returns [`InvalidUsecaseName`] if the name violates the rules on /// [`Usecase`]. - pub fn new(name: impl Into) -> Result { - let name = name.into(); - validate(&name)?; - Ok(Self(Name::Dynamic(name.into()))) + pub fn new(name: impl AsRef) -> Result { + let name = name.as_ref(); + validate(name)?; + Ok(Self(Repr::Dynamic(name.into()))) } /// Creates a usecase name from a name known at compile time. /// - /// Use [`Self::new`] for dynamic names. + /// Use [`Self::new`] for dynamic names. The name is stored without + /// allocating, so the result can initialize a `static`: + /// + /// ``` + /// use objectstore_types::usecase::Usecase; + /// + /// static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); + /// assert_eq!(ATTACHMENTS.as_str(), "attachments"); + /// ``` /// /// # Panics /// /// Panics if the name violates the rules on [`Usecase`]. In a const - /// context, this panic is reported as a compile error. + /// context, this panic is reported as a compile error: + /// + /// ```compile_fail + /// use objectstore_types::usecase::Usecase; + /// + /// static BROKEN: Usecase = Usecase::from_static("attachments/v2"); + /// ``` + #[track_caller] pub const fn from_static(name: &'static str) -> Self { match validate(name) { - Ok(()) => Self(Name::Static(name)), - Err(InvalidUsecaseName::InvalidLength) => { - panic!("invalid usecase name: must be 3–64 ASCII characters long") - } - Err(InvalidUsecaseName::InvalidChar(_)) => { - panic!("invalid usecase name: expected only ASCII letters, digits, '_' or '-'") - } + Ok(()) => Self(Repr::Static(name)), + // Const panics only accept a single `&str`, so the position is omitted. + Err(InvalidUsecaseName::InvalidLength) => panic!("{}", INVALID_LENGTH), + Err(InvalidUsecaseName::InvalidChar(_)) => panic!("{}", INVALID_CHAR), } } /// Returns the usecase name as a string slice. pub fn as_str(&self) -> &str { match &self.0 { - Name::Static(name) => name, - Name::Dynamic(name) => name, + Repr::Static(name) => name, + Repr::Dynamic(name) => name, } } } @@ -101,14 +119,19 @@ const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { while i < bytes.len() { let b = bytes[i]; if !b.is_ascii_alphanumeric() && b != b'_' && b != b'-' { - // All preceding bytes are ASCII, so `i` is a char boundary. - return Err(InvalidUsecaseName::InvalidChar(decode_char(bytes, i))); + return Err(InvalidUsecaseName::InvalidChar(i)); } i += 1; } Ok(()) } +impl fmt::Debug for Usecase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Usecase").field(&self.as_str()).finish() + } +} + impl PartialEq for Usecase { fn eq(&self, other: &Self) -> bool { self.as_str() == other.as_str() @@ -197,27 +220,25 @@ impl<'de> Deserialize<'de> for Usecase { where D: Deserializer<'de>, { - Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + deserializer.deserialize_str(UsecaseVisitor) } } -/// Decodes the UTF-8 encoded char starting at byte index `i` of a valid `str`. -const fn decode_char(bytes: &[u8], i: usize) -> char { - let lead = bytes[i] as u32; - let (len, mut code) = match lead { - 0x00..0x80 => (1, lead), - 0xC0..0xE0 => (2, lead & 0x1F), - 0xE0..0xF0 => (3, lead & 0x0F), - _ => (4, lead & 0x07), - }; - let mut j = 1; - while j < len { - code = (code << 6) | (bytes[i + j] as u32 & 0x3F); - j += 1; +/// Deserializes a [`Usecase`] from a string, borrowing it where possible. +struct UsecaseVisitor; + +impl serde::de::Visitor<'_> for UsecaseVisitor { + type Value = Usecase; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a usecase name") } - match char::from_u32(code) { - Some(c) => c, - None => char::REPLACEMENT_CHARACTER, + + fn visit_str(self, name: &str) -> Result + where + E: serde::de::Error, + { + Usecase::new(name).map_err(E::custom) } } @@ -225,11 +246,12 @@ const fn decode_char(bytes: &[u8], i: usize) -> char { #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum InvalidUsecaseName { /// The name is outside the permitted length range. - #[error("usecase name must be 3–64 ASCII characters long")] + #[error("{message}", message = INVALID_LENGTH)] InvalidLength, - /// The name contains a character outside the permitted set. - #[error("invalid character {0:?} in usecase name; expected ASCII letters, digits, '_' or '-'")] - InvalidChar(char), + /// The name contains a character outside the permitted set, starting at + /// this byte position. + #[error("{message}, at position {0}", message = INVALID_CHAR)] + InvalidChar(usize), } #[cfg(test)] @@ -247,23 +269,23 @@ mod tests { for name in ["", "_", "ab", &"a".repeat(65)] { assert_eq!(Usecase::new(name), Err(InvalidUsecaseName::InvalidLength)); } - for (name, character) in [ - ("testing/org=999/injected", '/'), - ("test.name", '.'), - ("test name", ' '), - ("café", 'é'), - ("ab€cd", '€'), - ("ab🦀cd", '🦀'), + for (name, position) in [ + ("testing/org=999/injected", 7), + ("test.name", 4), + ("test name", 4), + ("café", 3), + ("ab€cd", 2), + ("ab🦀cd", 2), ] { assert_eq!( Usecase::new(name), - Err(InvalidUsecaseName::InvalidChar(character)) - ); - assert_eq!( - validate(name), - Err(InvalidUsecaseName::InvalidChar(character)) + Err(InvalidUsecaseName::InvalidChar(position)) ); } + assert_eq!( + InvalidUsecaseName::InvalidChar(7).to_string(), + "invalid usecase name: expected ASCII letters, digits, '_' or '-', at position 7" + ); } #[test] @@ -286,6 +308,8 @@ mod tests { let static_ = Usecase::from_static("debug-files"); assert_eq!(dynamic, static_); assert_eq!(dynamic.cmp(&static_), Ordering::Equal); + assert_eq!(format!("{dynamic:?}"), r#"Usecase("debug-files")"#); + assert_eq!(format!("{static_:?}"), r#"Usecase("debug-files")"#); let set = HashSet::from([dynamic]); assert!(set.contains(&static_)); assert!(set.contains("debug-files")); @@ -297,6 +321,9 @@ mod tests { let serialized = serde_json::to_string(&name).unwrap(); assert_eq!(serialized, r#""Debug_Files-123""#); assert_eq!(serde_json::from_str::(&serialized).unwrap(), name); + // Escapes can't be borrowed from the input, so this takes the transient path. + let escaped = r#""Debug\u005fFiles-123""#; + assert_eq!(serde_json::from_str::(escaped).unwrap(), name); assert!(serde_json::from_str::(r#""_""#).is_err()); } From 7bacdff1909da0c56dac5eea89dea9fc4278436a Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Fri, 30 Oct 2026 13:27:53 +0100 Subject: [PATCH 3/4] fix: Reorder types --- objectstore-types/src/usecase.rs | 58 ++++++++++++++++---------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index b45648de..6c69e86d 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -109,23 +109,6 @@ impl Usecase { } } -/// Checks whether a name follows the rules on [`Usecase`]. -const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { - let bytes = name.as_bytes(); - if bytes.len() < MIN_USECASE_NAME_LENGTH || bytes.len() > MAX_USECASE_NAME_LENGTH { - return Err(InvalidUsecaseName::InvalidLength); - } - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - if !b.is_ascii_alphanumeric() && b != b'_' && b != b'-' { - return Err(InvalidUsecaseName::InvalidChar(i)); - } - i += 1; - } - Ok(()) -} - impl fmt::Debug for Usecase { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("Usecase").field(&self.as_str()).finish() @@ -224,6 +207,35 @@ impl<'de> Deserialize<'de> for Usecase { } } +/// An error indicating that a usecase name is invalid. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum InvalidUsecaseName { + /// The name is outside the permitted length range. + #[error("{message}", message = INVALID_LENGTH)] + InvalidLength, + /// The name contains a character outside the permitted set, starting at + /// this byte position. + #[error("{message}, at position {0}", message = INVALID_CHAR)] + InvalidChar(usize), +} + +/// Checks whether a name follows the rules on [`Usecase`]. +const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { + let bytes = name.as_bytes(); + if bytes.len() < MIN_USECASE_NAME_LENGTH || bytes.len() > MAX_USECASE_NAME_LENGTH { + return Err(InvalidUsecaseName::InvalidLength); + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_alphanumeric() && b != b'_' && b != b'-' { + return Err(InvalidUsecaseName::InvalidChar(i)); + } + i += 1; + } + Ok(()) +} + /// Deserializes a [`Usecase`] from a string, borrowing it where possible. struct UsecaseVisitor; @@ -242,18 +254,6 @@ impl serde::de::Visitor<'_> for UsecaseVisitor { } } -/// An error indicating that a usecase name is invalid. -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum InvalidUsecaseName { - /// The name is outside the permitted length range. - #[error("{message}", message = INVALID_LENGTH)] - InvalidLength, - /// The name contains a character outside the permitted set, starting at - /// this byte position. - #[error("{message}, at position {0}", message = INVALID_CHAR)] - InvalidChar(usize), -} - #[cfg(test)] mod tests { use super::*; From fd7329019d26380af1bc020791786385947dbcdd Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Fri, 30 Oct 2026 13:37:18 +0100 Subject: [PATCH 4/4] fix: Visitor --- objectstore-types/src/usecase.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index 6c69e86d..69387ece 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -236,7 +236,7 @@ const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { Ok(()) } -/// Deserializes a [`Usecase`] from a string, borrowing it where possible. +/// Deserializes a [`Usecase`] from a string or UTF-8 bytes. struct UsecaseVisitor; impl serde::de::Visitor<'_> for UsecaseVisitor { @@ -252,6 +252,15 @@ impl serde::de::Visitor<'_> for UsecaseVisitor { { Usecase::new(name).map_err(E::custom) } + + fn visit_bytes(self, bytes: &[u8]) -> Result + where + E: serde::de::Error, + { + let name = std::str::from_utf8(bytes) + .map_err(|_| E::invalid_value(serde::de::Unexpected::Bytes(bytes), &self))?; + self.visit_str(name) + } } #[cfg(test)]