From 064ad86385bab74546f1620c02425c3a3ddc181f Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Tue, 20 Oct 2026 14:57:06 +0200 Subject: [PATCH 1/2] ref: Introduce a validated usecase name type --- Cargo.lock | 2 + bigtable-bench/src/main.rs | 2 +- clients/rust/src/client.rs | 13 +- objectstore-options/Cargo.toml | 2 + objectstore-options/src/lib.rs | 17 +- objectstore-server/src/auth/context.rs | 13 +- objectstore-server/src/config.rs | 6 +- objectstore-server/src/endpoints/multipart.rs | 5 +- objectstore-server/src/endpoints/objects.rs | 2 +- objectstore-server/src/endpoints/resumable.rs | 5 +- objectstore-server/src/extractors/id.rs | 68 +++--- objectstore-server/src/killswitches.rs | 32 +-- objectstore-server/src/rate_limits.rs | 42 +++- objectstore-server/src/usecases.rs | 91 ++++++-- objectstore-server/tests/limits.rs | 10 +- objectstore-service/src/backend/bigtable.rs | 2 +- objectstore-service/src/backend/changelog.rs | 2 +- objectstore-service/src/backend/counting.rs | 35 ++-- objectstore-service/src/backend/gcs.rs | 4 +- objectstore-service/src/backend/in_memory.rs | 2 +- objectstore-service/src/backend/local_fs.rs | 16 +- .../src/backend/s3_compatible.rs | 2 +- objectstore-service/src/backend/tiered.rs | 34 +-- .../src/change_stream/cost_tracker.rs | 6 +- objectstore-service/src/id.rs | 32 +-- objectstore-service/src/service.rs | 2 +- objectstore-service/src/streaming.rs | 2 +- objectstore-types/src/usecase.rs | 197 +++++++++++++----- 28 files changed, 412 insertions(+), 234 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 581818fcf..baf1fae8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2875,7 +2875,9 @@ version = "0.1.0" dependencies = [ "objectstore-log", "objectstore-typed-options", + "objectstore-types", "serde", + "serde_json", "tokio", ] diff --git a/bigtable-bench/src/main.rs b/bigtable-bench/src/main.rs index 56a700fe6..e72ed6177 100644 --- a/bigtable-bench/src/main.rs +++ b/bigtable-bench/src/main.rs @@ -190,7 +190,7 @@ async fn main() -> anyhow::Result<()> { let worker_bytes = Arc::clone(&total_bytes); let worker_handle = tokio::spawn(async move { let context = ObjectContext { - usecase: "bench".into(), + usecase: "bench".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", "bench").unwrap(), // INVARIANT: valid scope name ]), diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index f11a311eb..511835aa6 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -6,7 +6,7 @@ use bytes::Bytes; use futures_util::stream::BoxStream; use objectstore_types::metadata::{Compression, ExpirationPolicy}; use objectstore_types::scope; -use objectstore_types::usecase::{self, InvalidUsecaseName}; +use objectstore_types::usecase::{InvalidUsecaseName, UsecaseName}; use reqwest::RequestBuilder; use url::Url; @@ -158,7 +158,7 @@ impl ClientBuilder { /// normalized. #[derive(Debug, Clone)] pub struct Usecase { - name: Arc, + name: Arc, compression: Option, expiration_policy: ExpirationPolicy, } @@ -184,9 +184,8 @@ impl Usecase { /// Returns [`InvalidUsecaseName`] if the name violates the naming rules on /// [`Usecase`]. pub fn try_new(name: &str) -> Result { - usecase::validate_usecase_name(name)?; Ok(Self { - name: name.into(), + name: Arc::new(UsecaseName::new(name)?), compression: Some(Compression::Zstd), expiration_policy: ExpirationPolicy::default(), }) @@ -195,7 +194,7 @@ impl Usecase { /// Returns the name of this usecase. #[inline] pub fn name(&self) -> &str { - &self.name + self.name.as_str() } /// Returns the compression algorithm to use for operations within this usecase. @@ -472,7 +471,7 @@ impl Session { segments .push("v1") .push("objects") - .push(&self.scope.usecase.name) + .push(self.scope.usecase.name()) .push(&self.scope.scopes.as_api_path().to_string()) .extend(object_key.split('/')); drop(segments); @@ -567,7 +566,7 @@ impl Session { Some("complete") => "objects:multipart:complete", _ => "objects:multipart", }) - .push(&self.scope.usecase.name) + .push(self.scope.usecase.name()) .push(&self.scope.scopes.as_api_path().to_string()); if let Some(object_key) = object_key.filter(|key| !key.is_empty()) { segments.extend(object_key.split('/')); diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index e9d3b32bf..937725fed 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -11,6 +11,7 @@ publish = false [dependencies] objectstore-log = { workspace = true } +objectstore-types = { workspace = true } objectstore-typed-options = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["rt", "time"] } @@ -19,3 +20,4 @@ tokio = { workspace = true, features = ["rt", "time"] } testing = [] [dev-dependencies] +serde_json = { workspace = true } diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 1684d0e94..4e6f870d4 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use objectstore_typed_options::SentryOptions; +use objectstore_types::usecase::UsecaseName; use serde::{Deserialize, Serialize}; pub use objectstore_typed_options::Error; @@ -59,11 +60,11 @@ impl Options { /// contexts and discard all requests. #[derive(Debug, Deserialize, Serialize, PartialEq)] pub struct Killswitch { - /// Optional usecase to match. + /// Optional validated usecase name to match exactly. /// /// If `None`, matches any usecase. #[serde(default)] - pub usecase: Option, + pub usecase: Option, /// Scopes to match. /// @@ -95,4 +96,16 @@ mod tests { fn schema_is_valid() { let _ = Options::get(); } + + #[test] + fn killswitch_deserialization_validates_usecase() { + let switch: Killswitch = serde_json::from_str(r#"{"usecase":"Debug_Files"}"#).unwrap(); + assert_eq!(switch.usecase.as_ref().unwrap().as_str(), "Debug_Files"); + let serialized = serde_json::to_value(&switch).unwrap(); + assert_eq!(serialized["usecase"], "Debug_Files"); + + let wildcard: Killswitch = serde_json::from_str("{}").unwrap(); + assert!(wildcard.usecase.is_none()); + assert!(serde_json::from_str::(r#"{"usecase":"test/name"}"#).is_err()); + } } diff --git a/objectstore-server/src/auth/context.rs b/objectstore-server/src/auth/context.rs index 067ef70ff..39180ff9a 100644 --- a/objectstore-server/src/auth/context.rs +++ b/objectstore-server/src/auth/context.rs @@ -6,6 +6,7 @@ use jsonwebtoken::{Algorithm, Header, TokenData, Validation, decode, decode_head use objectstore_service::id::ObjectContext; use objectstore_types::auth::Permission; use objectstore_types::presign::CanonicalRequest; +use objectstore_types::usecase::UsecaseName; use serde::{Deserialize, Serialize}; use crate::auth::KeyId; @@ -16,7 +17,7 @@ use crate::auth::util::StringOrWildcard; #[derive(Deserialize, Serialize, Debug, Clone)] struct JwtRes { #[serde(rename = "os:usecase")] - usecase: String, + usecase: UsecaseName, #[serde(flatten)] scope: BTreeMap, @@ -43,7 +44,7 @@ pub struct ScopedContext { /// The objectstore usecase that this request may act on. /// /// See also: [`ObjectContext::usecase`]. - pub usecase: String, + pub usecase: UsecaseName, /// The scope elements that this request may act on. /// @@ -351,7 +352,7 @@ mod tests { fn sample_auth_context(org: &str, proj: &str, permissions: HashSet) -> AuthContext { AuthContext::Scoped(ScopedContext { - usecase: "attachments".into(), + usecase: "attachments".parse().unwrap(), permissions, scopes: serde_json::from_value(json!({"org": org, "project": proj})).unwrap(), }) @@ -457,7 +458,7 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK fn sample_object_context(org: &str, project: &str) -> ObjectContext { ObjectContext { - usecase: "attachments".into(), + usecase: "attachments".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", org).unwrap(), Scope::create("project", project).unwrap(), @@ -498,7 +499,7 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK fn test_assert_authorized_org_only_path_allowed() -> Result<(), AuthError> { let auth_context = sample_auth_context("123", "456", max_permission()); let object = ObjectContext { - usecase: "attachments".into(), + usecase: "attachments".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; @@ -536,7 +537,7 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK else { panic!("expected a scoped auth context"); }; - scoped.usecase = "debug-files".into(); + scoped.usecase = "debug-files".parse().unwrap(); let auth_context = AuthContext::Scoped(scoped); let object = sample_object_context("123", "456"); diff --git a/objectstore-server/src/config.rs b/objectstore-server/src/config.rs index 68c1a22f3..4f914936e 100644 --- a/objectstore-server/src/config.rs +++ b/objectstore-server/src/config.rs @@ -1485,7 +1485,7 @@ mod tests { figment::Jail::expect_with(|_jail| { let expected = [ Killswitch { - usecase: Some("broken_usecase".into()), + usecase: Some("broken_usecase".parse().unwrap()), scopes: BTreeMap::new(), service: None, }, @@ -1508,7 +1508,7 @@ mod tests { service: None, }, Killswitch { - usecase: Some("attachments".into()), + usecase: Some("attachments".parse().unwrap()), scopes: BTreeMap::from([("org".into(), "42".into())]), service: Some("test-*".into()), }, @@ -1561,7 +1561,7 @@ mod tests { scope_pct: Some(25), rules: vec![ ThroughputRule { - usecase: Some("high_priority".to_string()), + usecase: Some("high_priority".parse().unwrap()), scopes: vec![("org".to_string(), "123".to_string())], rps: Some(500), pct: None, diff --git a/objectstore-server/src/endpoints/multipart.rs b/objectstore-server/src/endpoints/multipart.rs index 2da877c83..d67deaa78 100644 --- a/objectstore-server/src/endpoints/multipart.rs +++ b/objectstore-server/src/endpoints/multipart.rs @@ -102,10 +102,7 @@ async fn initiate_inner( // TODO: Update time_created in `complete`, when we have a Service API to mutate metadata. let metadata = Metadata::from_insert_headers(&headers, "", access_time)?; - state - .config - .usecases - .validate(&id.context().usecase, &metadata)?; + state.config.usecases.validate(id.usecase(), &metadata)?; let upload_id = service.initiate_multipart(id.clone(), metadata).await?; diff --git a/objectstore-server/src/endpoints/objects.rs b/objectstore-server/src/endpoints/objects.rs index 03271c62c..cdc3f28a6 100644 --- a/objectstore-server/src/endpoints/objects.rs +++ b/objectstore-server/src/endpoints/objects.rs @@ -397,7 +397,7 @@ mod tests { fn object_id(key: &str) -> ObjectId { ObjectId::new( ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "1").unwrap()]), }, key.into(), diff --git a/objectstore-server/src/endpoints/resumable.rs b/objectstore-server/src/endpoints/resumable.rs index 1b72edd7f..b8a03d001 100644 --- a/objectstore-server/src/endpoints/resumable.rs +++ b/objectstore-server/src/endpoints/resumable.rs @@ -105,10 +105,7 @@ async fn create_session_for_id( require_empty_body(content_length, body, "resumable session creation").await?; let metadata = Metadata::from_insert_headers(&headers, "", access_time)?; - state - .config - .usecases - .validate(&id.context().usecase, &metadata)?; + state.config.usecases.validate(id.usecase(), &metadata)?; let created = service .create_upload_session(id.clone(), metadata, total_length) diff --git a/objectstore-server/src/extractors/id.rs b/objectstore-server/src/extractors/id.rs index fd3d75cb0..2acfeff14 100644 --- a/objectstore-server/src/extractors/id.rs +++ b/objectstore-server/src/extractors/id.rs @@ -6,7 +6,7 @@ use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; use objectstore_service::id::{ObjectContext, ObjectId}; use objectstore_types::scope::{EMPTY_SCOPES, Scope, Scopes}; -use objectstore_types::usecase; +use objectstore_types::usecase::UsecaseName; use serde::{Deserialize, de}; use crate::extractors::Xt; @@ -82,23 +82,12 @@ impl FromRequestParts for Xt { /// This is meant to be used with the axum `Path` extractor. #[derive(Clone, Debug, Deserialize)] struct ObjectParams { - #[serde(deserialize_with = "deserialize_usecase")] - usecase: String, + usecase: UsecaseName, #[serde(deserialize_with = "deserialize_scopes")] scopes: Scopes, key: String, } -/// Deserializes and validates a usecase name. -fn deserialize_usecase<'de, D>(deserializer: D) -> Result -where - D: de::Deserializer<'de>, -{ - let name = String::deserialize(deserializer)?; - usecase::validate_usecase_name(&name).map_err(de::Error::custom)?; - Ok(name) -} - /// Deserializes a `Scopes` instance from a string representation. /// /// The string representation is a semicolon-separated list of `key=value` pairs, following the @@ -167,15 +156,14 @@ impl FromRequestParts for Xt { /// (`/objects/{usecase}/{scopes}/{*key}`) routes — the extra `key` parameter is ignored. #[derive(Clone, Debug, Deserialize)] pub(super) struct ContextParams { - #[serde(deserialize_with = "deserialize_usecase")] - pub usecase: String, + pub usecase: UsecaseName, #[serde(deserialize_with = "deserialize_scopes")] pub scopes: Scopes, } fn populate_sentry_context(context: &ObjectContext) { sentry::configure_scope(|s| { - s.set_tag("usecase", &context.usecase); + s.set_tag("usecase", context.usecase.as_str()); for scope in &context.scopes { s.set_tag(&format!("scope.{}", scope.name()), scope.value()); } @@ -196,31 +184,29 @@ mod tests { #[test] fn parse_usecase() { - for name in [ - "Debug_Files-123".to_owned(), - "a".repeat(64), - "___".to_owned(), - ] { - let deserializer: CowStrDeserializer = - Cow::Borrowed(name.as_str()).into_deserializer(); - assert_eq!(deserialize_usecase(deserializer).unwrap(), name); - } - for name in [ - "", - "_", - "ab", - "testing/org=999/injected", - "test.name", - "test%2Fname", - "café", + for (name, valid) in [ + ("Debug_Files-123", true), + ("___", true), + ("_", false), + ("test/name", false), + ("test%2Fname", false), ] { - let deserializer: CowStrDeserializer = Cow::Borrowed(name).into_deserializer(); - assert!(deserialize_usecase(deserializer).is_err(), "{name:?}"); + let params = serde_json::json!({ + "usecase": name, + "scopes": "org=123", + "key": "my-key", + }); + assert_eq!( + serde_json::from_value::(params.clone()).is_ok(), + valid, + "object usecase {name:?}", + ); + assert_eq!( + serde_json::from_value::(params).is_ok(), + valid, + "context usecase {name:?}", + ); } - let name = "a".repeat(65); - let deserializer: CowStrDeserializer = - Cow::Borrowed(name.as_str()).into_deserializer(); - assert!(deserialize_usecase(deserializer).is_err()); } #[test] @@ -443,7 +429,7 @@ mod tests { async fn extract_object_id_killswitched() { let config = Config { killswitches: Killswitches::new(vec![Killswitch { - usecase: Some("blocked".into()), + usecase: Some("blocked".parse().unwrap()), scopes: BTreeMap::new(), service: None, }]), @@ -471,7 +457,7 @@ mod tests { async fn extract_object_context_killswitched() { let config = Config { killswitches: Killswitches::new(vec![Killswitch { - usecase: Some("blocked".into()), + usecase: Some("blocked".parse().unwrap()), scopes: BTreeMap::new(), service: None, }]), diff --git a/objectstore-server/src/killswitches.rs b/objectstore-server/src/killswitches.rs index 27796fe45..ad3af41f9 100644 --- a/objectstore-server/src/killswitches.rs +++ b/objectstore-server/src/killswitches.rs @@ -151,7 +151,7 @@ mod tests { }; let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; @@ -161,20 +161,20 @@ mod tests { #[test] fn test_matches_usecase() { let switch = Killswitch { - usecase: Some("test".to_string()), + usecase: Some("test".parse().unwrap()), scopes: BTreeMap::new(), service: None, }; let context = ObjectContext { - usecase: "test".to_string(), + usecase: "test".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; assert!(matches(&switch, &context, Some("anyservice"), &mut cache())); // usecase differs let context = ObjectContext { - usecase: "other".to_string(), + usecase: "other".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; assert!(!matches( @@ -198,7 +198,7 @@ mod tests { // match, ignoring extra scope let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", "123").unwrap(), Scope::create("project", "456").unwrap(), @@ -209,7 +209,7 @@ mod tests { // project differs let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", "123").unwrap(), Scope::create("project", "999").unwrap(), @@ -224,7 +224,7 @@ mod tests { // missing project let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; assert!(!matches( @@ -238,14 +238,14 @@ mod tests { #[test] fn test_matches_full() { let switch = Killswitch { - usecase: Some("test".to_string()), + usecase: Some("test".parse().unwrap()), scopes: BTreeMap::from([("org".to_string(), "123".to_string())]), service: Some("myservice-*".to_string()), }; // match with all filters let context = ObjectContext { - usecase: "test".to_string(), + usecase: "test".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; assert!(matches( @@ -257,7 +257,7 @@ mod tests { // usecase differs let context = ObjectContext { - usecase: "other".to_string(), + usecase: "other".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; assert!(!matches( @@ -269,7 +269,7 @@ mod tests { // scope differs let context = ObjectContext { - usecase: "test".to_string(), + usecase: "test".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]), }; assert!(!matches( @@ -281,7 +281,7 @@ mod tests { // service differs let context = ObjectContext { - usecase: "test".to_string(), + usecase: "test".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; assert!(!matches( @@ -293,7 +293,7 @@ mod tests { // missing service header let context = ObjectContext { - usecase: "test".to_string(), + usecase: "test".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; assert!(!matches(&switch, &context, None, &mut cache())); @@ -308,7 +308,7 @@ mod tests { }; let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; @@ -331,7 +331,7 @@ mod tests { }; let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; @@ -382,7 +382,7 @@ mod tests { }; let context = ObjectContext { - usecase: "any".to_string(), + usecase: "any".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; diff --git a/objectstore-server/src/rate_limits.rs b/objectstore-server/src/rate_limits.rs index c3a84f77a..685874429 100644 --- a/objectstore-server/src/rate_limits.rs +++ b/objectstore-server/src/rate_limits.rs @@ -20,6 +20,7 @@ use bytes::Bytes; use futures_util::Stream; use objectstore_service::id::ObjectContext; use objectstore_types::scope::Scopes; +use objectstore_types::usecase::UsecaseName; use serde::{Deserialize, Serialize}; /// Identifies which rate limit triggered a rejection. @@ -113,10 +114,10 @@ pub struct ThroughputLimits { /// bucket — all matching rules must admit the request. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct ThroughputRule { - /// Optional usecase to match. + /// Optional validated usecase name to match exactly. /// /// If `None`, matches any usecase. - pub usecase: Option, + pub usecase: Option, /// Scopes to match. /// @@ -262,11 +263,11 @@ impl RateLimiter { objectstore_metrics::count!( "server.request.rate_limited", reason = rejection.as_str(), - usecase = context.usecase.clone() + usecase = context.usecase.to_string() ); objectstore_log::warn!( reason = rejection.as_str(), - usecase = &context.usecase, + usecase = %context.usecase, scopes = %context.scopes.as_api_path(), key, "Request rejected: rate limit exceeded" @@ -381,7 +382,7 @@ struct BandwidthRateLimiter { total_bytes: Arc, // NB: These maps grow unbounded but we accept this as we expect an overall limited // number of usecases and scopes. We emit gauge metrics to monitor their size. - usecases: Arc>>, + usecases: Arc>>, scopes: Arc>>, } @@ -531,7 +532,7 @@ struct ThroughputRateLimiter { total_admitted: Arc, // NB: These maps grow unbounded but we accept this as we expect an overall limited // number of usecases and scopes. We emit gauge metrics to monitor their size. - usecases: Arc>>, + usecases: Arc>>, scopes: Arc>>, rules: papaya::HashMap>, } @@ -765,11 +766,38 @@ mod tests { fn make_context() -> ObjectContext { ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "1").unwrap()]), } } + #[test] + fn throughput_rule_deserialization_validates_usecase() { + let rule: ThroughputRule = serde_json::from_value(serde_json::json!({ + "usecase": "testing", + "scopes": [], + "rps": 10, + })) + .unwrap(); + assert!(rule.matches(&make_context())); + assert_eq!(serde_json::to_value(&rule).unwrap()["usecase"], "testing"); + + let wildcard: ThroughputRule = serde_json::from_value(serde_json::json!({ + "scopes": [], + "rps": 10, + })) + .unwrap(); + assert!(wildcard.matches(&make_context())); + assert!( + serde_json::from_value::(serde_json::json!({ + "usecase": "test/name", + "scopes": [], + "rps": 10, + })) + .is_err() + ); + } + // --- BandwidthBucket unit tests (explicit `now`, no sleeps) --- #[test] diff --git a/objectstore-server/src/usecases.rs b/objectstore-server/src/usecases.rs index b2c379794..8edf2f88b 100644 --- a/objectstore-server/src/usecases.rs +++ b/objectstore-server/src/usecases.rs @@ -1,6 +1,6 @@ //! Configuration and validation for use case properties. //! -//! Use cases are user-defined strings that namespace objects (e.g. `attachments`, +//! Use cases are user-defined identifiers that namespace objects (e.g. `attachments`, //! `debug-files`). This module provides a central place to configure properties //! of use cases, such as which expiration policies are permitted and any duration //! caps. @@ -29,22 +29,24 @@ use std::time::Duration; use objectstore_types::duration::format_duration; use objectstore_types::metadata::{ExpirationPolicy, Metadata}; +use objectstore_types::usecase::UsecaseName; use serde::{Deserialize, Serialize}; use thiserror::Error; /// Configuration for all use case properties. /// -/// Maps use case names to their configuration. Use cases not present in the map +/// Maps validated use case names to their configuration. Invalid names are +/// rejected when deserializing configuration. Use cases not present in the map /// receive the default configuration (all expiration policies allowed, no caps). #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)] -pub struct UseCases(pub HashMap); +pub struct UseCases(pub HashMap); impl UseCases { /// Validates metadata against the configuration for the given use case. /// /// Returns an error if any metadata field violates the use case's policy. /// Use cases not present in the configuration are always valid. - pub fn validate(&self, usecase: &str, metadata: &Metadata) -> Result<(), UseCaseError> { + pub fn validate(&self, usecase: &UsecaseName, metadata: &Metadata) -> Result<(), UseCaseError> { if let Some(config) = self.0.get(usecase) { config.validate(usecase, metadata)? } @@ -54,7 +56,7 @@ impl UseCases { /// Returns the maximum allowed expiration duration for the given use case, if configured. /// /// Applies to both TTL and TTI expiration policies. `None` means no limit. - pub fn get_max_expiry(&self, usecase: &str) -> Option { + pub fn get_max_expiry(&self, usecase: &UsecaseName) -> Option { self.0.get(usecase).and_then(|config| config.expiration.max) } } @@ -68,7 +70,7 @@ pub struct UseCaseConfig { } impl UseCaseConfig { - fn validate(&self, usecase: &str, metadata: &Metadata) -> Result<(), UseCaseError> { + fn validate(&self, usecase: &UsecaseName, metadata: &Metadata) -> Result<(), UseCaseError> { let policy = metadata.expiration_policy; let allowed = match policy { ExpirationPolicy::Manual => self.expiration.manual.allowed, @@ -135,7 +137,7 @@ pub enum UseCaseError { #[error("expiration policy '{policy}' is not allowed for use case '{usecase}'")] PolicyNotAllowed { /// The use case name. - usecase: String, + usecase: UsecaseName, /// The disallowed policy, in wire format. policy: ExpirationPolicy, }, @@ -144,7 +146,7 @@ pub enum UseCaseError { #[error("expiration duration {duration} exceeds maximum of {max} for use case '{usecase}'")] DurationExceeded { /// The use case name. - usecase: String, + usecase: UsecaseName, /// The requested duration, in wire format. duration: String, /// The configured maximum, in wire format. @@ -169,31 +171,52 @@ mod tests { fn usecases_from(config: UseCaseConfig) -> UseCases { let mut map = HashMap::new(); - map.insert("test".to_owned(), config); + map.insert("test".parse().unwrap(), config); UseCases(map) } + #[test] + fn configuration_validates_usecase_keys() { + let config: UseCases = serde_json::from_str(r#"{"Debug_Files":{}}"#).unwrap(); + assert!(config.0.contains_key("Debug_Files")); + assert!(!config.0.contains_key("debug_files")); + let roundtrip: UseCases = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert_eq!(roundtrip, config); + + for name in ["_", "testing/org=999/injected"] { + let config = serde_json::json!({name: {}}); + assert!(serde_json::from_value::(config).is_err()); + } + } + // --- unconfigured use case --- #[test] fn unconfigured_usecase_allows_manual() { let usecases = UseCases::default(); let metadata = make_metadata(ExpirationPolicy::Manual); - usecases.validate("anything", &metadata).unwrap(); + usecases + .validate(&"anything".parse().unwrap(), &metadata) + .unwrap(); } #[test] fn unconfigured_usecase_allows_ttl() { let usecases = UseCases::default(); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1))); - usecases.validate("anything", &metadata).unwrap(); + usecases + .validate(&"anything".parse().unwrap(), &metadata) + .unwrap(); } #[test] fn unconfigured_usecase_allows_tti() { let usecases = UseCases::default(); let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1))); - usecases.validate("anything", &metadata).unwrap(); + usecases + .validate(&"anything".parse().unwrap(), &metadata) + .unwrap(); } // --- manual policy --- @@ -208,7 +231,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::Manual); - let err = usecases.validate("test", &metadata).unwrap_err(); + let err = usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap_err(); assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. })); } @@ -223,7 +248,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::Manual); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); } // --- ttl policy --- @@ -238,7 +265,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1))); - let err = usecases.validate("test", &metadata).unwrap_err(); + let err = usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap_err(); assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. })); } @@ -252,7 +281,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1))); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); } #[test] @@ -265,7 +296,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1))); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); } #[test] @@ -278,7 +311,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(2))); - let err = usecases.validate("test", &metadata).unwrap_err(); + let err = usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap_err(); assert!(matches!(err, UseCaseError::DurationExceeded { .. })); } @@ -294,7 +329,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1))); - let err = usecases.validate("test", &metadata).unwrap_err(); + let err = usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap_err(); assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. })); } @@ -308,7 +345,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1))); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); } #[test] @@ -321,7 +360,9 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(2))); - let err = usecases.validate("test", &metadata).unwrap_err(); + let err = usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap_err(); assert!(matches!(err, UseCaseError::DurationExceeded { .. })); } @@ -337,9 +378,13 @@ mod tests { }); let metadata = make_metadata(ExpirationPolicy::Manual); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1))); - usecases.validate("test", &metadata).unwrap(); + usecases + .validate(&"test".parse().unwrap(), &metadata) + .unwrap(); } } diff --git a/objectstore-server/tests/limits.rs b/objectstore-server/tests/limits.rs index 49c2611e0..193cdec43 100644 --- a/objectstore-server/tests/limits.rs +++ b/objectstore-server/tests/limits.rs @@ -65,7 +65,7 @@ async fn test_web_concurrency_limit() -> Result<()> { async fn test_killswitches() -> Result<()> { let server = TestServer::with_config(Config { killswitches: Killswitches::new(vec![Killswitch { - usecase: Some("blocked".to_string()), + usecase: Some("blocked".parse().unwrap()), scopes: BTreeMap::from_iter([("org".to_string(), "42".to_string())]), service: Some("test-*".to_string()), }]), @@ -285,7 +285,7 @@ async fn test_throughput_rule() -> Result<()> { global_rps: None, burst: 0, rules: vec![ThroughputRule { - usecase: Some("restricted".to_string()), + usecase: Some("restricted".parse().unwrap()), scopes: vec![("org".to_string(), "42".to_string())], rps: Some(1), pct: None, @@ -684,7 +684,7 @@ async fn test_usecase_expiration_policy() -> Result<()> { }, usecases: UseCases(HashMap::from([ ( - "attachments".to_owned(), + "attachments".parse().unwrap(), UseCaseConfig { expiration: ExpirationConfig { manual: PolicyConfig { allowed: false }, @@ -695,7 +695,7 @@ async fn test_usecase_expiration_policy() -> Result<()> { }, ), ( - "debug-files".to_owned(), + "debug-files".parse().unwrap(), UseCaseConfig { expiration: ExpirationConfig { manual: PolicyConfig { allowed: false }, @@ -706,7 +706,7 @@ async fn test_usecase_expiration_policy() -> Result<()> { }, ), ( - "avatars".to_owned(), + "avatars".parse().unwrap(), UseCaseConfig { expiration: ExpirationConfig { manual: PolicyConfig { allowed: true }, diff --git a/objectstore-service/src/backend/bigtable.rs b/objectstore-service/src/backend/bigtable.rs index 392424b62..04cae8698 100644 --- a/objectstore-service/src/backend/bigtable.rs +++ b/objectstore-service/src/backend/bigtable.rs @@ -1658,7 +1658,7 @@ mod tests { fn make_id() -> ObjectId { ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }) } diff --git a/objectstore-service/src/backend/changelog.rs b/objectstore-service/src/backend/changelog.rs index 612636ecc..dd6923946 100644 --- a/objectstore-service/src/backend/changelog.rs +++ b/objectstore-service/src/backend/changelog.rs @@ -511,7 +511,7 @@ mod tests { fn make_id(key: &str) -> ObjectId { ObjectId::new( ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }, key.into(), diff --git a/objectstore-service/src/backend/counting.rs b/objectstore-service/src/backend/counting.rs index e3fc56ec1..53a4898d4 100644 --- a/objectstore-service/src/backend/counting.rs +++ b/objectstore-service/src/backend/counting.rs @@ -21,6 +21,7 @@ use objectstore_types::metadata::Metadata; use objectstore_types::range::ByteRange; use objectstore_types::resumable::UploadProgress; use objectstore_types::time::Timestamp; +use objectstore_types::usecase::UsecaseName; use crate::backend::common::{ Backend, DeleteResponse, ExpiryUpdate, GetResponse, MetadataResponse, MultipartUploadBackend, @@ -39,8 +40,8 @@ use crate::stream::ClientStream; /// /// Under the hood, the `usecase` is used as the `app_feature`. This allows to identify distinct /// products and map them in the for the COGs pipeline. -fn count(usecase: &str) { - objectstore_metrics::count!("cogs.usage" += 1, app_feature = usecase.to_owned()); +fn count(usecase: &UsecaseName) { + objectstore_metrics::count!("cogs.usage" += 1, app_feature = usecase.to_string()); } /// A [`Backend`] decorator that counts each operation performed for COGS. Also implements @@ -82,7 +83,7 @@ impl Backend for CountingBackend { stream: ClientStream, access_time: Timestamp, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .put_object(id, metadata, stream, access_time) .await @@ -94,7 +95,7 @@ impl Backend for CountingBackend { access_time: Timestamp, range: Option, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner.get_object(id, access_time, range).await } @@ -103,7 +104,7 @@ impl Backend for CountingBackend { id: &ObjectId, access_time: Timestamp, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner.get_metadata(id, access_time).await } @@ -113,12 +114,12 @@ impl Backend for CountingBackend { target: ExpiryUpdate, access_time: Timestamp, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner.set_expiry(id, target, access_time).await } async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner.delete_object(id, access_time).await } @@ -137,7 +138,7 @@ impl Backend for CountingBackend { metadata: &Metadata, upload_length: NonZeroU64, ) -> Result> { - count(&id.context.usecase); + count(id.usecase()); self.inner .create_upload_session(id, metadata, upload_length) .await @@ -150,19 +151,19 @@ impl Backend for CountingBackend { content_length: u64, stream: ClientStream, ) -> Result { - count(&session.object_id.context.usecase); + count(session.object_id.usecase()); self.inner .put_chunk(session, offset, content_length, stream) .await } async fn upload_offset(&self, session: &Session) -> Result { - count(&session.object_id.context.usecase); + count(session.object_id.usecase()); self.inner.upload_offset(session).await } async fn cancel_upload(&self, session: &Session) -> Result<()> { - count(&session.object_id.context.usecase); + count(session.object_id.usecase()); self.inner.cancel_upload(session).await } } @@ -174,7 +175,7 @@ impl MultipartUploadBackend for CountingBackend { id: &ObjectId, metadata: &Metadata, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .as_multipart_upload_backend()? .initiate_multipart(id, metadata) @@ -190,7 +191,7 @@ impl MultipartUploadBackend for CountingBackend { content_md5: Option<&str>, body: ClientStream, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .as_multipart_upload_backend()? .upload_part( @@ -211,7 +212,7 @@ impl MultipartUploadBackend for CountingBackend { max_parts: Option, part_number_marker: Option, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .as_multipart_upload_backend()? .list_parts(id, upload_id, max_parts, part_number_marker) @@ -223,7 +224,7 @@ impl MultipartUploadBackend for CountingBackend { id: &ObjectId, upload_id: &UploadId, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .as_multipart_upload_backend()? .abort_multipart(id, upload_id) @@ -237,7 +238,7 @@ impl MultipartUploadBackend for CountingBackend { parts: Vec, access_time: Timestamp, ) -> Result { - count(&id.context.usecase); + count(id.usecase()); self.inner .as_multipart_upload_backend()? .complete_multipart(id, upload_id, parts, access_time) @@ -257,7 +258,7 @@ mod tests { fn object_id(usecase: &str) -> ObjectId { ObjectId::new( ObjectContext { - usecase: usecase.into(), + usecase: usecase.parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "1").unwrap()]), }, "key".into(), diff --git a/objectstore-service/src/backend/gcs.rs b/objectstore-service/src/backend/gcs.rs index b556aa18d..25b3d9440 100644 --- a/objectstore-service/src/backend/gcs.rs +++ b/objectstore-service/src/backend/gcs.rs @@ -1923,7 +1923,7 @@ mod tests { fn make_id() -> ObjectId { ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }) } @@ -1931,7 +1931,7 @@ mod tests { fn make_id_with_key(key: &str) -> ObjectId { ObjectId::new( ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("organization", "42").unwrap(), Scope::create("project", "7").unwrap(), diff --git a/objectstore-service/src/backend/in_memory.rs b/objectstore-service/src/backend/in_memory.rs index 1f2a83ff9..c3f0896b4 100644 --- a/objectstore-service/src/backend/in_memory.rs +++ b/objectstore-service/src/backend/in_memory.rs @@ -988,7 +988,7 @@ mod tests { fn make_id() -> ObjectId { ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }) } diff --git a/objectstore-service/src/backend/local_fs.rs b/objectstore-service/src/backend/local_fs.rs index 02809cfdd..f38a6cf9a 100644 --- a/objectstore-service/src/backend/local_fs.rs +++ b/objectstore-service/src/backend/local_fs.rs @@ -1361,7 +1361,7 @@ mod tests { ); let id = ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }); @@ -1411,7 +1411,11 @@ mod tests { let mut slots = HashMap::new(); let (first_id, colliding_id) = (0..=65_536) .find_map(|key| { - let id = ObjectId::from_parts("testing".into(), Scopes::empty(), key.to_string()); + let id = ObjectId::from_parts( + "testing".parse().unwrap(), + Scopes::empty(), + key.to_string(), + ); let path = first_locks.path(&id); slots.insert(path, id.clone()).map(|first| (first, id)) }) @@ -1450,7 +1454,7 @@ mod tests { #[tokio::test] async fn missing_object_expiry_does_not_block_descendant() { let (_tempdir, backend) = make_backend(); - let id = ObjectId::from_parts("testing".into(), Scopes::empty(), "foo".into()); + let id = ObjectId::from_parts("testing".parse().unwrap(), Scopes::empty(), "foo".into()); assert_eq!( backend .set_expiry( @@ -1679,7 +1683,7 @@ mod tests { ); let id = ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }); @@ -1721,7 +1725,7 @@ mod tests { ); let id = ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }); @@ -1731,7 +1735,7 @@ mod tests { fn make_id() -> ObjectId { ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }) } diff --git a/objectstore-service/src/backend/s3_compatible.rs b/objectstore-service/src/backend/s3_compatible.rs index 154cc81a0..d8c0e6454 100644 --- a/objectstore-service/src/backend/s3_compatible.rs +++ b/objectstore-service/src/backend/s3_compatible.rs @@ -587,7 +587,7 @@ mod tests { fn make_id() -> ObjectId { ObjectId::random(ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), }) } diff --git a/objectstore-service/src/backend/tiered.rs b/objectstore-service/src/backend/tiered.rs index fdfd61726..ed383cf41 100644 --- a/objectstore-service/src/backend/tiered.rs +++ b/objectstore-service/src/backend/tiered.rs @@ -660,15 +660,15 @@ impl Backend for TieredStorage { access_time: Timestamp, ) -> Result { let mut timer = - objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_owned(),); + objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_string(),); if metadata.origin.is_none() { - objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_owned()); + objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_string()); } let peeked = SizedPeek::new(stream, BACKEND_SIZE_THRESHOLD).await?; objectstore_metrics::record!( "put.first_chunk.latency" = timer.elapsed(), - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), complete = if peeked.is_exhausted() { "yes" } else { "no" }, ); @@ -691,7 +691,7 @@ impl Backend for TieredStorage { timer.record(); objectstore_metrics::record!( "put.size" = stored_size, - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = backend_choice.as_str(), backend_type = backend_ty, upload_type = "direct", @@ -709,7 +709,7 @@ impl Backend for TieredStorage { ) -> Result { let mut timer = objectstore_metrics::timer!( "get.latency.pre-response", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), ); self.tag_backend(&mut timer, &BackendChoice::HighVolume); @@ -744,7 +744,7 @@ impl Backend for TieredStorage { if let Some(size) = size { objectstore_metrics::record!( "get.size" = size, - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = backend_choice.as_str(), backend_type = backend_type, ); @@ -761,7 +761,7 @@ impl Backend for TieredStorage { access_time: Timestamp, ) -> Result { let mut timer = - objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_owned()); + objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_string()); self.tag_backend(&mut timer, &BackendChoice::HighVolume); let hv_result = self @@ -840,7 +840,7 @@ impl Backend for TieredStorage { #[tracing::instrument(level = "debug", skip(self))] async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result { let mut timer = - objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_owned()); + objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_string()); self.tag_backend(&mut timer, &BackendChoice::HighVolume); if let Some(tombstone) = self @@ -976,7 +976,7 @@ impl MultipartUploadBackend for TieredStorage { ) -> Result { let timer = objectstore_metrics::timer!( "multipart.initiate.latency", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), ); @@ -1010,7 +1010,7 @@ impl MultipartUploadBackend for TieredStorage { ) -> Result { let timer = objectstore_metrics::timer!( "multipart.upload_part.latency", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), ); @@ -1037,7 +1037,7 @@ impl MultipartUploadBackend for TieredStorage { timer.record(); objectstore_metrics::record!( "multipart.upload_part.size" = content_length, - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), ); Ok(etag) @@ -1053,7 +1053,7 @@ impl MultipartUploadBackend for TieredStorage { ) -> Result { let timer = objectstore_metrics::timer!( "multipart.list_parts.latency", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), ); @@ -1082,7 +1082,7 @@ impl MultipartUploadBackend for TieredStorage { ) -> Result { let timer = objectstore_metrics::timer!( "multipart.abort.latency", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), ); @@ -1113,7 +1113,7 @@ impl MultipartUploadBackend for TieredStorage { ) -> Result { let timer = objectstore_metrics::timer!( "multipart.complete.latency", - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), ); @@ -1237,12 +1237,12 @@ impl MultipartUploadBackend for TieredStorage { timer.record(); objectstore_metrics::record!( "multipart.complete.part_count" = part_count as u64, - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), ); if let Some(size) = metadata.size { objectstore_metrics::record!( "put.size" = size as u64, - usecase = id.usecase().to_owned(), + usecase = id.usecase().to_string(), backend_choice = BackendChoice::LongTerm.as_str(), backend_type = self.backend_type(&BackendChoice::LongTerm), upload_type = "multipart", @@ -1275,7 +1275,7 @@ mod tests { fn make_context() -> ObjectContext { ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), } } diff --git a/objectstore-service/src/change_stream/cost_tracker.rs b/objectstore-service/src/change_stream/cost_tracker.rs index 973413466..8355897b6 100644 --- a/objectstore-service/src/change_stream/cost_tracker.rs +++ b/objectstore-service/src/change_stream/cost_tracker.rs @@ -76,7 +76,7 @@ where async fn commit_write(&self, id: &ObjectId, size: u64, expires_at: Option) { let result = self.tracker.write( &id.as_storage_path().to_string(), - id.usecase(), + id.usecase().as_str(), size, SystemTime::now(), expires_at.map(Into::into), @@ -89,7 +89,7 @@ where async fn commit_update(&self, id: &ObjectId, expires_at: Option) { let result = self.tracker.update( &id.as_storage_path().to_string(), - id.usecase(), + id.usecase().as_str(), SystemTime::now(), expires_at.map(Into::into), scope_id(id, SCOPE_ORGANIZATION), @@ -101,7 +101,7 @@ where async fn commit_delete(&self, id: &ObjectId) { let result = self.tracker.delete( &id.as_storage_path().to_string(), - id.usecase(), + id.usecase().as_str(), SystemTime::now(), ); self.swallow("delete", result); diff --git a/objectstore-service/src/id.rs b/objectstore-service/src/id.rs index 4d8459f01..1b533eac3 100644 --- a/objectstore-service/src/id.rs +++ b/objectstore-service/src/id.rs @@ -12,6 +12,7 @@ //! A usecase (sometimes called a "product") is a top-level namespace like //! `"attachments"` or `"debug-files"`. It groups related objects and can have //! usecase-specific server configuration (rate limits, killswitches, etc.). +//! [`UsecaseName`] validates the name when it is constructed or deserialized. //! //! ## Scopes //! @@ -36,6 +37,7 @@ use std::fmt; use objectstore_types::scope::{Scope, Scopes}; +use objectstore_types::usecase::UsecaseName; /// The fixed path segment that separates scopes from the object key in storage paths. const KEY_DELIMITER: &str = "objects"; @@ -49,7 +51,7 @@ pub struct ObjectContext { /// /// This can be defined on-the-fly by the client, but special server logic /// (such as the concrete backend/bucket) can be tied to this as well. - pub usecase: String, + pub usecase: UsecaseName, /// The scopes of the object, used for compartmentalization and authorization. /// @@ -77,7 +79,7 @@ pub struct ObjectContext { /// use objectstore_types::scope::{Scope, Scopes}; /// /// let object_id = ObjectContext { - /// usecase: "my_usecase".to_string(), + /// usecase: "my_usecase".parse().unwrap(), /// scopes: Scopes::from_iter([ /// Scope::create("organization", "17").unwrap(), /// Scope::create("project", "42").unwrap(), @@ -115,7 +117,7 @@ impl ObjectId { } /// Creates a new `ObjectId` from all of its parts. - pub fn from_parts(usecase: String, scopes: Scopes, key: String) -> Self { + pub fn from_parts(usecase: UsecaseName, scopes: Scopes, key: String) -> Self { Self::new(ObjectContext { usecase, scopes }, key) } @@ -145,9 +147,7 @@ impl ObjectId { pub fn from_storage_path(path: &str) -> Option { // Split off the usecase (everything before the first `/`). let (usecase, mut rest) = path.split_once('/')?; - if usecase.is_empty() { - return None; - } + let usecase = UsecaseName::new(usecase).ok()?; let mut scopes_vec = Vec::new(); loop { @@ -166,7 +166,7 @@ impl ObjectId { "" => None, key => Some(ObjectId { context: ObjectContext { - usecase: usecase.to_owned(), + usecase, scopes: Scopes::from_iter(scopes_vec), }, key: key.to_owned(), @@ -191,7 +191,7 @@ impl ObjectId { /// Returns the usecase of the object. /// /// See [`ObjectContext::usecase`] for more information. - pub fn usecase(&self) -> &str { + pub fn usecase(&self) -> &UsecaseName { &self.context.usecase } @@ -255,7 +255,7 @@ mod tests { fn test_storage_path() { let object_id = ObjectId { context: ObjectContext { - usecase: "testing".to_string(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", "12345").unwrap(), Scope::create("project", "1337").unwrap(), @@ -272,7 +272,7 @@ mod tests { fn test_storage_path_empty_scopes() { let object_id = ObjectId { context: ObjectContext { - usecase: "testing".to_string(), + usecase: "testing".parse().unwrap(), scopes: Scopes::empty(), }, key: "foo/bar".to_string(), @@ -288,7 +288,7 @@ mod tests { // Multiple scopes ObjectId { context: ObjectContext { - usecase: "attachments".to_string(), + usecase: "attachments".parse().unwrap(), scopes: Scopes::from_iter([ Scope::create("org", "17").unwrap(), Scope::create("project", "42").unwrap(), @@ -299,7 +299,7 @@ mod tests { // No scopes ObjectId { context: ObjectContext { - usecase: "debug-files".to_string(), + usecase: "debug-files".parse().unwrap(), scopes: Scopes::empty(), }, key: "simple-key".to_string(), @@ -307,7 +307,7 @@ mod tests { // Key containing slashes ObjectId { context: ObjectContext { - usecase: "testing".to_string(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "12345").unwrap()]), }, key: "foo/bar/baz".to_string(), @@ -315,7 +315,7 @@ mod tests { // Single scope ObjectId { context: ObjectContext { - usecase: "replays".to_string(), + usecase: "replays".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("org", "99").unwrap()]), }, key: "some-uuid-key".to_string(), @@ -332,6 +332,10 @@ mod tests { #[test] fn test_from_storage_path_invalid() { + // Invalid usecase names must not bypass validation through storage paths. + for name in ["", "_", "ab", "test.name", "café", &"a".repeat(65)] { + assert!(ObjectId::from_storage_path(&format!("{name}/objects/key")).is_none()); + } // Missing "objects" segment assert!(ObjectId::from_storage_path("usecase/org.17/mykey").is_none()); // Empty path diff --git a/objectstore-service/src/service.rs b/objectstore-service/src/service.rs index 5326663c2..38ac193d2 100644 --- a/objectstore-service/src/service.rs +++ b/objectstore-service/src/service.rs @@ -641,7 +641,7 @@ mod tests { fn make_context() -> ObjectContext { ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), } } diff --git a/objectstore-service/src/streaming.rs b/objectstore-service/src/streaming.rs index 7e0643459..86d03b82c 100644 --- a/objectstore-service/src/streaming.rs +++ b/objectstore-service/src/streaming.rs @@ -380,7 +380,7 @@ mod tests { fn make_context() -> ObjectContext { ObjectContext { - usecase: "testing".into(), + usecase: "testing".parse().unwrap(), scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]), } } diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index f677736d0..ddeb5003b 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -1,4 +1,10 @@ -//! Validation for usecase names shared by clients and the server. +//! Validated usecase names shared by clients and the server. + +use std::borrow::Borrow; +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize}; /// Minimum length of a usecase name in bytes. pub const MIN_USECASE_NAME_LENGTH: usize = 3; @@ -6,6 +12,110 @@ pub const MIN_USECASE_NAME_LENGTH: usize = 3; /// Maximum length of a usecase name in bytes. pub const MAX_USECASE_NAME_LENGTH: usize = 64; +/// A validated, case-sensitive name for an Objectstore workload. +/// +/// Names must be 3–64 ASCII characters from `[A-Za-z0-9_-]`. Any permitted +/// character may appear in any position, including names consisting entirely of +/// underscores or hyphens. Names are never normalized. +/// +/// Construction and deserialization both validate the name. The contents cannot +/// be mutated after construction. +/// +/// ``` +/// use objectstore_types::usecase::UsecaseName; +/// +/// let name = UsecaseName::new("debug-files")?; +/// assert_eq!(name.as_str(), "debug-files"); +/// assert!(UsecaseName::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 UsecaseName(String); + +impl UsecaseName { + /// Creates a validated usecase name from a borrowed or owned string. + /// + /// # Errors + /// + /// Returns [`InvalidUsecaseName`] if the name violates the rules on + /// [`UsecaseName`]. + 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)); + } + } + Ok(Self(name)) + } + + /// Returns the usecase name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for UsecaseName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Borrow for UsecaseName { + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for UsecaseName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for UsecaseName { + type Err = InvalidUsecaseName; + + fn from_str(name: &str) -> Result { + Self::new(name) + } +} + +impl TryFrom<&str> for UsecaseName { + type Error = InvalidUsecaseName; + + fn try_from(name: &str) -> Result { + Self::new(name) + } +} + +impl TryFrom for UsecaseName { + type Error = InvalidUsecaseName; + + fn try_from(name: String) -> Result { + Self::new(name) + } +} + +impl From for String { + fn from(name: UsecaseName) -> Self { + name.0 + } +} + +impl<'de> Deserialize<'de> for UsecaseName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + /// An error indicating that a usecase name is invalid. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum InvalidUsecaseName { @@ -17,67 +127,56 @@ pub enum InvalidUsecaseName { InvalidChar(char), } -/// Validates a usecase name. -/// -/// Names must be 3–64 ASCII characters long and contain only letters (`A–Z`, -/// `a–z`), digits (`0–9`), underscores (`_`), and hyphens (`-`). Any permitted -/// character may appear in any position, including names consisting entirely of -/// underscores or hyphens. Names are case-sensitive and are never normalized. -/// -/// # Errors -/// -/// Returns [`InvalidUsecaseName`] if the byte length is outside the permitted -/// range or the name contains a forbidden character. -pub fn validate_usecase_name(name: &str) -> Result<(), InvalidUsecaseName> { - 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)); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn length_boundaries() { - for len in [0, 1, 2, 3, 64, 65] { - let result = validate_usecase_name(&"a".repeat(len)); - if (3..=64).contains(&len) { - assert_eq!(result, Ok(())); - } else { - assert_eq!(result, Err(InvalidUsecaseName::InvalidLength)); - } - } - } - - #[test] - fn allowed_characters() { + fn validates_names() { for name in [ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-", "___", - "---", - "-a_", ] { - assert_eq!(validate_usecase_name(name), Ok(())); + assert_eq!(UsecaseName::new(name).unwrap().as_str(), name); } - } - - #[test] - fn forbidden_characters() { - for c in [ - '/', '\\', '.', '%', '=', ';', '?', '#', ' ', '\t', '\n', '\0', 'é', + for name in ["", "_", "ab", &"a".repeat(65)] { + assert_eq!( + UsecaseName::new(name), + Err(InvalidUsecaseName::InvalidLength) + ); + } + for (name, character) in [ + ("testing/org=999/injected", '/'), + ("test.name", '.'), + ("test name", ' '), + ("café", 'é'), ] { assert_eq!( - validate_usecase_name(&format!("test{c}")), - Err(InvalidUsecaseName::InvalidChar(c)), + UsecaseName::new(name), + Err(InvalidUsecaseName::InvalidChar(character)) ); } - assert!(validate_usecase_name("testing/org=999/injected").is_err()); + } + + #[test] + fn serde_validates_and_uses_strings() { + let name = UsecaseName::new("Debug_Files-123").unwrap(); + let serialized = serde_json::to_string(&name).unwrap(); + assert_eq!(serialized, r#""Debug_Files-123""#); + assert_eq!( + serde_json::from_str::(&serialized).unwrap(), + name + ); + assert!(serde_json::from_str::(r#""_""#).is_err()); + } + + #[test] + fn string_conversions_preserve_names() { + let input = "Debug_Files-123"; + let name: UsecaseName = input.parse().unwrap(); + assert_eq!(name.as_ref(), input); + assert_eq!(name.to_string(), input); + let owned = String::from(name); + assert_eq!(UsecaseName::try_from(owned).unwrap().as_str(), input); } } From 190ae3771ee0c2aa6be41068bac9582fd3938b80 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Tue, 20 Oct 2026 15:24:06 +0200 Subject: [PATCH 2/2] ref: Rename UsecaseName to Usecase --- clients/rust/src/client.rs | 2 +- objectstore-options/src/lib.rs | 4 +- objectstore-server/src/auth/context.rs | 6 +-- objectstore-server/src/extractors/id.rs | 6 +-- objectstore-server/src/rate_limits.rs | 8 ++-- objectstore-server/src/usecases.rs | 14 +++--- objectstore-service/src/backend/counting.rs | 4 +- objectstore-service/src/id.rs | 12 ++--- objectstore-types/src/usecase.rs | 52 +++++++++------------ 9 files changed, 51 insertions(+), 57 deletions(-) diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 511835aa6..64eb0cd14 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -6,7 +6,7 @@ use bytes::Bytes; use futures_util::stream::BoxStream; use objectstore_types::metadata::{Compression, ExpirationPolicy}; use objectstore_types::scope; -use objectstore_types::usecase::{InvalidUsecaseName, UsecaseName}; +use objectstore_types::usecase::{InvalidUsecaseName, Usecase as UsecaseName}; use reqwest::RequestBuilder; use url::Url; diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 4e6f870d4..409c06f51 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use objectstore_typed_options::SentryOptions; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use serde::{Deserialize, Serialize}; pub use objectstore_typed_options::Error; @@ -64,7 +64,7 @@ pub struct Killswitch { /// /// If `None`, matches any usecase. #[serde(default)] - pub usecase: Option, + pub usecase: Option, /// Scopes to match. /// diff --git a/objectstore-server/src/auth/context.rs b/objectstore-server/src/auth/context.rs index 39180ff9a..1c4a6dc23 100644 --- a/objectstore-server/src/auth/context.rs +++ b/objectstore-server/src/auth/context.rs @@ -6,7 +6,7 @@ use jsonwebtoken::{Algorithm, Header, TokenData, Validation, decode, decode_head use objectstore_service::id::ObjectContext; use objectstore_types::auth::Permission; use objectstore_types::presign::CanonicalRequest; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use serde::{Deserialize, Serialize}; use crate::auth::KeyId; @@ -17,7 +17,7 @@ use crate::auth::util::StringOrWildcard; #[derive(Deserialize, Serialize, Debug, Clone)] struct JwtRes { #[serde(rename = "os:usecase")] - usecase: UsecaseName, + usecase: Usecase, #[serde(flatten)] scope: BTreeMap, @@ -44,7 +44,7 @@ pub struct ScopedContext { /// The objectstore usecase that this request may act on. /// /// See also: [`ObjectContext::usecase`]. - pub usecase: UsecaseName, + pub usecase: Usecase, /// The scope elements that this request may act on. /// diff --git a/objectstore-server/src/extractors/id.rs b/objectstore-server/src/extractors/id.rs index 2acfeff14..362a77ba2 100644 --- a/objectstore-server/src/extractors/id.rs +++ b/objectstore-server/src/extractors/id.rs @@ -6,7 +6,7 @@ use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; use objectstore_service::id::{ObjectContext, ObjectId}; use objectstore_types::scope::{EMPTY_SCOPES, Scope, Scopes}; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use serde::{Deserialize, de}; use crate::extractors::Xt; @@ -82,7 +82,7 @@ impl FromRequestParts for Xt { /// This is meant to be used with the axum `Path` extractor. #[derive(Clone, Debug, Deserialize)] struct ObjectParams { - usecase: UsecaseName, + usecase: Usecase, #[serde(deserialize_with = "deserialize_scopes")] scopes: Scopes, key: String, @@ -156,7 +156,7 @@ impl FromRequestParts for Xt { /// (`/objects/{usecase}/{scopes}/{*key}`) routes — the extra `key` parameter is ignored. #[derive(Clone, Debug, Deserialize)] pub(super) struct ContextParams { - pub usecase: UsecaseName, + pub usecase: Usecase, #[serde(deserialize_with = "deserialize_scopes")] pub scopes: Scopes, } diff --git a/objectstore-server/src/rate_limits.rs b/objectstore-server/src/rate_limits.rs index 685874429..763bcde6d 100644 --- a/objectstore-server/src/rate_limits.rs +++ b/objectstore-server/src/rate_limits.rs @@ -20,7 +20,7 @@ use bytes::Bytes; use futures_util::Stream; use objectstore_service::id::ObjectContext; use objectstore_types::scope::Scopes; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use serde::{Deserialize, Serialize}; /// Identifies which rate limit triggered a rejection. @@ -117,7 +117,7 @@ pub struct ThroughputRule { /// Optional validated usecase name to match exactly. /// /// If `None`, matches any usecase. - pub usecase: Option, + pub usecase: Option, /// Scopes to match. /// @@ -382,7 +382,7 @@ struct BandwidthRateLimiter { total_bytes: Arc, // NB: These maps grow unbounded but we accept this as we expect an overall limited // number of usecases and scopes. We emit gauge metrics to monitor their size. - usecases: Arc>>, + usecases: Arc>>, scopes: Arc>>, } @@ -532,7 +532,7 @@ struct ThroughputRateLimiter { total_admitted: Arc, // NB: These maps grow unbounded but we accept this as we expect an overall limited // number of usecases and scopes. We emit gauge metrics to monitor their size. - usecases: Arc>>, + usecases: Arc>>, scopes: Arc>>, rules: papaya::HashMap>, } diff --git a/objectstore-server/src/usecases.rs b/objectstore-server/src/usecases.rs index 8edf2f88b..011c6565a 100644 --- a/objectstore-server/src/usecases.rs +++ b/objectstore-server/src/usecases.rs @@ -29,7 +29,7 @@ use std::time::Duration; use objectstore_types::duration::format_duration; use objectstore_types::metadata::{ExpirationPolicy, Metadata}; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -39,14 +39,14 @@ use thiserror::Error; /// rejected when deserializing configuration. Use cases not present in the map /// receive the default configuration (all expiration policies allowed, no caps). #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)] -pub struct UseCases(pub HashMap); +pub struct UseCases(pub HashMap); impl UseCases { /// Validates metadata against the configuration for the given use case. /// /// Returns an error if any metadata field violates the use case's policy. /// Use cases not present in the configuration are always valid. - pub fn validate(&self, usecase: &UsecaseName, metadata: &Metadata) -> Result<(), UseCaseError> { + pub fn validate(&self, usecase: &Usecase, metadata: &Metadata) -> Result<(), UseCaseError> { if let Some(config) = self.0.get(usecase) { config.validate(usecase, metadata)? } @@ -56,7 +56,7 @@ impl UseCases { /// Returns the maximum allowed expiration duration for the given use case, if configured. /// /// Applies to both TTL and TTI expiration policies. `None` means no limit. - pub fn get_max_expiry(&self, usecase: &UsecaseName) -> Option { + pub fn get_max_expiry(&self, usecase: &Usecase) -> Option { self.0.get(usecase).and_then(|config| config.expiration.max) } } @@ -70,7 +70,7 @@ pub struct UseCaseConfig { } impl UseCaseConfig { - fn validate(&self, usecase: &UsecaseName, metadata: &Metadata) -> Result<(), UseCaseError> { + fn validate(&self, usecase: &Usecase, metadata: &Metadata) -> Result<(), UseCaseError> { let policy = metadata.expiration_policy; let allowed = match policy { ExpirationPolicy::Manual => self.expiration.manual.allowed, @@ -137,7 +137,7 @@ pub enum UseCaseError { #[error("expiration policy '{policy}' is not allowed for use case '{usecase}'")] PolicyNotAllowed { /// The use case name. - usecase: UsecaseName, + usecase: Usecase, /// The disallowed policy, in wire format. policy: ExpirationPolicy, }, @@ -146,7 +146,7 @@ pub enum UseCaseError { #[error("expiration duration {duration} exceeds maximum of {max} for use case '{usecase}'")] DurationExceeded { /// The use case name. - usecase: UsecaseName, + usecase: Usecase, /// The requested duration, in wire format. duration: String, /// The configured maximum, in wire format. diff --git a/objectstore-service/src/backend/counting.rs b/objectstore-service/src/backend/counting.rs index 53a4898d4..23664d24a 100644 --- a/objectstore-service/src/backend/counting.rs +++ b/objectstore-service/src/backend/counting.rs @@ -21,7 +21,7 @@ use objectstore_types::metadata::Metadata; use objectstore_types::range::ByteRange; use objectstore_types::resumable::UploadProgress; use objectstore_types::time::Timestamp; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; use crate::backend::common::{ Backend, DeleteResponse, ExpiryUpdate, GetResponse, MetadataResponse, MultipartUploadBackend, @@ -40,7 +40,7 @@ use crate::stream::ClientStream; /// /// Under the hood, the `usecase` is used as the `app_feature`. This allows to identify distinct /// products and map them in the for the COGs pipeline. -fn count(usecase: &UsecaseName) { +fn count(usecase: &Usecase) { objectstore_metrics::count!("cogs.usage" += 1, app_feature = usecase.to_string()); } diff --git a/objectstore-service/src/id.rs b/objectstore-service/src/id.rs index 1b533eac3..3d7263c66 100644 --- a/objectstore-service/src/id.rs +++ b/objectstore-service/src/id.rs @@ -12,7 +12,7 @@ //! A usecase (sometimes called a "product") is a top-level namespace like //! `"attachments"` or `"debug-files"`. It groups related objects and can have //! usecase-specific server configuration (rate limits, killswitches, etc.). -//! [`UsecaseName`] validates the name when it is constructed or deserialized. +//! [`Usecase`] validates the name when it is constructed or deserialized. //! //! ## Scopes //! @@ -37,7 +37,7 @@ use std::fmt; use objectstore_types::scope::{Scope, Scopes}; -use objectstore_types::usecase::UsecaseName; +use objectstore_types::usecase::Usecase; /// The fixed path segment that separates scopes from the object key in storage paths. const KEY_DELIMITER: &str = "objects"; @@ -51,7 +51,7 @@ pub struct ObjectContext { /// /// This can be defined on-the-fly by the client, but special server logic /// (such as the concrete backend/bucket) can be tied to this as well. - pub usecase: UsecaseName, + pub usecase: Usecase, /// The scopes of the object, used for compartmentalization and authorization. /// @@ -117,7 +117,7 @@ impl ObjectId { } /// Creates a new `ObjectId` from all of its parts. - pub fn from_parts(usecase: UsecaseName, scopes: Scopes, key: String) -> Self { + pub fn from_parts(usecase: Usecase, scopes: Scopes, key: String) -> Self { Self::new(ObjectContext { usecase, scopes }, key) } @@ -147,7 +147,7 @@ impl ObjectId { pub fn from_storage_path(path: &str) -> Option { // Split off the usecase (everything before the first `/`). let (usecase, mut rest) = path.split_once('/')?; - let usecase = UsecaseName::new(usecase).ok()?; + let usecase = Usecase::new(usecase).ok()?; let mut scopes_vec = Vec::new(); loop { @@ -191,7 +191,7 @@ impl ObjectId { /// Returns the usecase of the object. /// /// See [`ObjectContext::usecase`] for more information. - pub fn usecase(&self) -> &UsecaseName { + pub fn usecase(&self) -> &Usecase { &self.context.usecase } diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index ddeb5003b..02fa5f661 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -22,24 +22,24 @@ pub const MAX_USECASE_NAME_LENGTH: usize = 64; /// be mutated after construction. /// /// ``` -/// use objectstore_types::usecase::UsecaseName; +/// use objectstore_types::usecase::Usecase; /// -/// let name = UsecaseName::new("debug-files")?; +/// let name = Usecase::new("debug-files")?; /// assert_eq!(name.as_str(), "debug-files"); -/// assert!(UsecaseName::new("testing/org=999/injected").is_err()); +/// 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 UsecaseName(String); +pub struct Usecase(String); -impl UsecaseName { +impl Usecase { /// Creates a validated usecase name from a borrowed or owned string. /// /// # Errors /// /// Returns [`InvalidUsecaseName`] if the name violates the rules on - /// [`UsecaseName`]. + /// [`Usecase`]. pub fn new(name: impl Into) -> Result { let name = name.into(); if !(MIN_USECASE_NAME_LENGTH..=MAX_USECASE_NAME_LENGTH).contains(&name.len()) { @@ -59,25 +59,25 @@ impl UsecaseName { } } -impl AsRef for UsecaseName { +impl AsRef for Usecase { fn as_ref(&self) -> &str { self.as_str() } } -impl Borrow for UsecaseName { +impl Borrow for Usecase { fn borrow(&self) -> &str { self.as_str() } } -impl fmt::Display for UsecaseName { +impl fmt::Display for Usecase { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl FromStr for UsecaseName { +impl FromStr for Usecase { type Err = InvalidUsecaseName; fn from_str(name: &str) -> Result { @@ -85,7 +85,7 @@ impl FromStr for UsecaseName { } } -impl TryFrom<&str> for UsecaseName { +impl TryFrom<&str> for Usecase { type Error = InvalidUsecaseName; fn try_from(name: &str) -> Result { @@ -93,7 +93,7 @@ impl TryFrom<&str> for UsecaseName { } } -impl TryFrom for UsecaseName { +impl TryFrom for Usecase { type Error = InvalidUsecaseName; fn try_from(name: String) -> Result { @@ -101,13 +101,13 @@ impl TryFrom for UsecaseName { } } -impl From for String { - fn from(name: UsecaseName) -> Self { +impl From for String { + fn from(name: Usecase) -> Self { name.0 } } -impl<'de> Deserialize<'de> for UsecaseName { +impl<'de> Deserialize<'de> for Usecase { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -137,13 +137,10 @@ mod tests { "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-", "___", ] { - assert_eq!(UsecaseName::new(name).unwrap().as_str(), name); + assert_eq!(Usecase::new(name).unwrap().as_str(), name); } for name in ["", "_", "ab", &"a".repeat(65)] { - assert_eq!( - UsecaseName::new(name), - Err(InvalidUsecaseName::InvalidLength) - ); + assert_eq!(Usecase::new(name), Err(InvalidUsecaseName::InvalidLength)); } for (name, character) in [ ("testing/org=999/injected", '/'), @@ -152,7 +149,7 @@ mod tests { ("café", 'é'), ] { assert_eq!( - UsecaseName::new(name), + Usecase::new(name), Err(InvalidUsecaseName::InvalidChar(character)) ); } @@ -160,23 +157,20 @@ mod tests { #[test] fn serde_validates_and_uses_strings() { - let name = UsecaseName::new("Debug_Files-123").unwrap(); + let name = Usecase::new("Debug_Files-123").unwrap(); let serialized = serde_json::to_string(&name).unwrap(); assert_eq!(serialized, r#""Debug_Files-123""#); - assert_eq!( - serde_json::from_str::(&serialized).unwrap(), - name - ); - assert!(serde_json::from_str::(r#""_""#).is_err()); + assert_eq!(serde_json::from_str::(&serialized).unwrap(), name); + assert!(serde_json::from_str::(r#""_""#).is_err()); } #[test] fn string_conversions_preserve_names() { let input = "Debug_Files-123"; - let name: UsecaseName = input.parse().unwrap(); + let name: Usecase = input.parse().unwrap(); assert_eq!(name.as_ref(), input); assert_eq!(name.to_string(), input); let owned = String::from(name); - assert_eq!(UsecaseName::try_from(owned).unwrap().as_str(), input); + assert_eq!(Usecase::try_from(owned).unwrap().as_str(), input); } }