diff --git a/sdk/cosmos/.cspell.json b/sdk/cosmos/.cspell.json index 1878559621..f263d93569 100644 --- a/sdk/cosmos/.cspell.json +++ b/sdk/cosmos/.cspell.json @@ -219,6 +219,7 @@ "undrained", "unfaulted", "unparseable", + "unrepresentable", "unroutable", "unsharded", "upsert", diff --git a/sdk/cosmos/azure_data_cosmos_driver/CHANGELOG.md b/sdk/cosmos/azure_data_cosmos_driver/CHANGELOG.md index f5fe325142..819c2c03d1 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/CHANGELOG.md +++ b/sdk/cosmos/azure_data_cosmos_driver/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added driver-internal resolution of containers by resource id (RID). `CosmosDriver::resolve_container_by_rid` reads a container's metadata addressing it purely by RID (deriving the parent database RID from the container RID, so no `read_database` round-trip is needed) and caches the result in a by-RID index. References are validated for consistent name/RID addressing in `plan_operation` — the single choke point every executable operation passes through, including multi-page queries — returning a deterministic `CLIENT_MIXED_NAME_RID_ADDRESSING` error before signing instead of letting the gateway reject a mixed name/RID request with an opaque `401`. The new `CLIENT_INVALID_RESOURCE_ID` and `CLIENT_MIXED_NAME_RID_ADDRESSING` client statuses carry searchable names for diagnostics. ([#4663](https://github.com/Azure/azure-sdk-for-rust/pull/4663)) + ### Breaking Changes - Resource-reference accessors now return `Option` to account for RID-addressed references that have no name. `DatabaseReference::name_based_path`, `ContainerReference::database_name`, and `ContainerReference::name_based_path` return `None` when the reference is addressed by RID (previously they returned `&str`/`String` and assumed a name was always present). Use the new `ContainerReference::base_path` to obtain the addressing-appropriate path (RID-based or name-based) when building request URLs. ([#4640](https://github.com/Azure/azure-sdk-for-rust/pull/4640)) diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/cache/container_cache.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/cache/container_cache.rs index 3fd6056d4d..650cd1bcb1 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/cache/container_cache.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/cache/container_cache.rs @@ -5,8 +5,10 @@ //! //! Maintains two lookup indices — by name and by RID — so that a resolved //! [`ContainerReference`] can be retrieved efficiently regardless of which -//! identifier the caller has. When a reference is fetched or inserted, -//! both caches are cross-populated to keep them in sync. +//! identifier the caller has. Name-addressed references are cross-populated +//! into both indices to keep them in sync; RID-addressed references are stored +//! only in the by-RID index, since they carry no database name to form a safe +//! by-name key. use super::AsyncCache; use crate::models::ContainerReference; @@ -25,21 +27,14 @@ struct ContainerNameKey { } impl ContainerNameKey { - fn from_container(c: &ContainerReference) -> Self { - Self { + /// Builds a name key from a container reference, or `None` if the container + /// was addressed by RID (no database name is available to key on). + fn from_container(c: &ContainerReference) -> Option { + Some(Self { account_endpoint: c.account().endpoint().as_str().to_owned(), - // TODO(Slice 2): `database_name()` is `None` for a RID-addressed - // container, so `unwrap_or_default()` collapses the key to - // `{endpoint, "", container_name}`. That is harmless today because - // nothing produces RID-addressed references yet, but once Slice 2's - // resolution layer starts caching them, two RID-resolved containers - // that share a name across different databases would alias to the - // same by-name key. Slice 2 must skip the by-name index for - // RID-addressed containers (or key it differently) before relying on - // this path. - db_name: c.database_name().unwrap_or_default().to_owned(), + db_name: c.database_name()?.to_owned(), container_name: c.name().to_owned(), - } + }) } } @@ -70,8 +65,10 @@ impl ContainerRidKey { /// /// Stores fully-resolved [`ContainerReference`] values and indexes them by /// both name (`account + db_name + container_name`) and RID -/// (`account + container_rid`). When a reference is fetched or inserted via -/// either index, the other index is cross-populated automatically. +/// (`account + container_rid`). A name-addressed reference fetched or inserted +/// via either index is cross-populated into the other automatically; a +/// RID-addressed reference is stored only in the by-RID index, because it has +/// no database name to form a safe by-name key. /// /// Uses single-pending-I/O semantics per key — concurrent requests for the /// same container share one fetch operation. @@ -114,6 +111,29 @@ impl ContainerCache { self.get_or_fetch_impl(&self.by_name, key, fetch_fn).await } + /// Looks up a container by RID, fetching if not cached. + /// + /// On a cache miss, calls `fetch_fn` to resolve the container from the + /// service. The resolved (RID-addressed) reference is populated into the + /// by-RID cache; the by-name cache is left untouched because a RID-addressed + /// reference has no database name to key on. + pub(crate) async fn get_or_fetch_by_rid( + &self, + account_endpoint: &str, + container_rid: &str, + fetch_fn: F, + ) -> crate::error::Result> + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + let key = ContainerRidKey { + account_endpoint: account_endpoint.to_owned(), + container_rid: container_rid.to_owned(), + }; + self.get_or_fetch_impl(&self.by_rid, key, fetch_fn).await + } + /// Returns a cached container looked up by name, or `None` if not cached. #[allow(dead_code)] // Used in tests; will be called from production code once lookup-by-name is wired up. pub(crate) async fn get_by_name( @@ -197,17 +217,20 @@ impl ContainerCache { /// Inserts a known-resolved container reference into both caches. /// /// If an entry already exists under either key, the existing entry is - /// preserved (first-write-wins). + /// preserved (first-write-wins). RID-addressed references are inserted only + /// into the by-RID cache, since they carry no database name to key on. pub(crate) async fn put(&self, container: ContainerReference) { let name_key = ContainerNameKey::from_container(&container); let rid_key = ContainerRidKey::from_container(&container); - let container_for_rid = container.clone(); - self.by_name - .get_or_insert_with(name_key, || async { Ok(container) }) - .await; + if let Some(name_key) = name_key { + let container_for_name = container.clone(); + self.by_name + .get_or_insert_with(name_key, || async { Ok(container_for_name) }) + .await; + } self.by_rid - .get_or_insert_with(rid_key, || async { Ok(container_for_rid) }) + .get_or_insert_with(rid_key, || async { Ok(container) }) .await; } } @@ -261,6 +284,16 @@ mod tests { ) } + fn test_container_by_rid(db_rid: &str, container_rid: &str) -> ContainerReference { + ContainerReference::new_by_rid( + test_account(), + db_rid.to_owned(), + "testcontainer".to_owned(), + container_rid.to_owned(), + &test_container_props(), + ) + } + // --- get_or_fetch_by_name --- #[tokio::test] @@ -343,6 +376,64 @@ mod tests { assert!(cache.get_by_rid(ACCOUNT_ENDPOINT, &rid).await.is_some()); } + // --- get_or_fetch_by_rid --- + + #[tokio::test] + async fn fetch_by_rid_caches_without_name_index() { + let cache = ContainerCache::new(); + let counter = Arc::new(AtomicUsize::new(0)); + + let container = test_container_by_rid("db_rid", "coll_rid"); + let container_clone = container.clone(); + let counter_clone = counter.clone(); + + let resolved = cache + .get_or_fetch_by_rid(ACCOUNT_ENDPOINT, "coll_rid", || async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(container_clone) + }) + .await + .unwrap(); + + assert!(resolved.is_by_rid()); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + // Retrievable by RID. + assert!(cache + .get_by_rid(ACCOUNT_ENDPOINT, "coll_rid") + .await + .is_some()); + + // A second fetch is served from cache, not the factory. + let counter2 = counter.clone(); + cache + .get_or_fetch_by_rid(ACCOUNT_ENDPOINT, "coll_rid", || async move { + counter2.fetch_add(1, Ordering::SeqCst); + Ok(test_container_by_rid("db_rid", "coll_rid")) + }) + .await + .unwrap(); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn put_rid_only_skips_name_cache() { + let cache = ContainerCache::new(); + let container = test_container_by_rid("db_rid", "coll_rid"); + + cache.put(container).await; + + // The by-RID index is populated, the by-name index is not (no db name). + assert!(cache + .get_by_rid(ACCOUNT_ENDPOINT, "coll_rid") + .await + .is_some()); + assert!(cache + .get_by_name(ACCOUNT_ENDPOINT, "db_rid", "testcontainer") + .await + .is_none()); + } + // --- different containers --- #[tokio::test] diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/cosmos_driver.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/cosmos_driver.rs index e6c4ec53cc..88998b92d0 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/cosmos_driver.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/cosmos_driver.rs @@ -1094,6 +1094,87 @@ impl CosmosDriver { )) } + /// Fetches a container's metadata from the service addressing it purely by RID. + /// + /// The parent database RID is derived from the container RID's encoded byte + /// layout (the first 4 decoded bytes), so no `read_database` round-trip is + /// needed. The read response supplies the container's name and partition key. + /// The resulting [`ContainerReference`] is RID-addressed (it carries no + /// database name). + async fn fetch_container_by_rid( + &self, + container_rid: &str, + ) -> crate::error::Result { + // A container RID decodes to at least 8 bytes: the first 4 identify the + // parent database, the next 4 the container. Anything shorter (e.g. a + // bare database RID) is not a container RID — fail fast rather than + // issuing a request that the service would reject. + let decoded = crate::models::resource_id::decode_rid(container_rid).map_err(|e| { + crate::error::CosmosError::builder() + .with_status(crate::error::CosmosStatus::CLIENT_INVALID_RESOURCE_ID) + .with_message(format!("invalid container RID '{container_rid}'")) + .with_source(e) + .build() + })?; + if decoded.len() < 8 { + return Err(crate::error::CosmosError::builder() + .with_status(crate::error::CosmosStatus::CLIENT_INVALID_RESOURCE_ID) + .with_message(format!( + "'{container_rid}' is not a container RID (decodes to {} bytes; a container RID requires at least 8)", + decoded.len() + )) + .with_source(std::io::Error::other("container RID too short")) + .build()); + } + let db_rid = crate::models::resource_id::ResourceId::new( + crate::models::resource_id::encode_rid(&decoded[0..4]), + ); + + let options = OperationOptions::default(); + + let container_result = self + .execute_singleton_operation( + CosmosOperation::read_container_by_rid( + self.account().clone(), + db_rid.as_str().to_owned(), + container_rid.to_owned(), + ), + options, + ) + .await?; + let container_headers = container_result.headers().clone(); + let container_diagnostics = container_result.diagnostics(); + let container_props: ContainerProperties = + container_result.into_body().into_single().map_err(|e| { + crate::error::CosmosError::builder() + .with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID) + .with_message("failed to deserialize container response") + .with_response_parts(crate::models::CosmosResponsePayload::new( + crate::models::ResponseBody::NoPayload, + container_headers.clone(), + )) + .with_diagnostics(container_diagnostics.clone()) + .with_source(e) + .build() + })?; + + // Prefer the authoritative RID echoed back by the service; fall back to + // the caller-supplied RID if the response omits it. + let resolved_rid = container_props + .system_properties + .rid + .clone() + .unwrap_or_else(|| container_rid.to_owned()); + + Ok(ContainerReference::new_by_rid( + self.account().clone(), + db_rid.as_str().to_owned(), + container_props.id.clone().into_owned(), + resolved_rid, + &container_props, + )) + } + /// Creates a new driver instance. /// /// This is internal - use [`CosmosDriverRuntime::create_driver()`] instead. @@ -2141,6 +2222,38 @@ impl CosmosDriver { Ok(resolved.as_ref().clone()) } + /// Resolves a container by its RID. + /// + /// Attempts to resolve from `ContainerCache` (by-RID index) first. On a cache + /// miss, fetches metadata from the service addressing the container by RID and + /// populates the cache. The returned [`ContainerReference`] is RID-addressed + /// (it carries no database name). + pub async fn resolve_container_by_rid( + &self, + container_rid: &str, + ) -> crate::error::Result { + let endpoint = self.account().endpoint().as_str().to_owned(); + let container_rid_owned = container_rid.to_owned(); + + let resolved = self + .runtime + .container_cache() + .get_or_fetch_by_rid(&endpoint, container_rid, || async move { + self.fetch_container_by_rid(&container_rid_owned) + .await + .map_err(|err| { + crate::error::CosmosErrorBuilder::from_error(err) + .with_context(format!( + "resolve container by rid (container_rid='{container_rid_owned}')" + )) + .build() + }) + }) + .await?; + + Ok(resolved.as_ref().clone()) + } + /// Plans the execution of a Cosmos DB operation. /// /// For trivial operations (non-query or single-partition), returns a @@ -2161,6 +2274,19 @@ impl CosmosDriver { options: &OperationOptions, continuation: Option<&ContinuationToken>, ) -> crate::error::Result { + // Reject mixed name/RID addressing before any IO work is done. A + // reference that mixes a name-addressed parent with a RID-addressed child + // (or vice versa) signs and routes inconsistently and the gateway rejects + // it with an opaque 401. Validating here — the single choke point every + // externally executable operation passes through on its way to a plan — + // turns that into a deterministic client-side error for references built + // through any path (including direct `plan_operation` + `execute_plan` + // callers and multi-page queries), not just `execute_operation`. The + // check is a cheap in-memory field comparison and a no-op for every + // consistently-addressed reference, so it issues no additional network + // calls and does not change the request flow. + operation.resource_reference().validate_addressing()?; + if !self.initialized.load(Ordering::Acquire) { let endpoint = AccountEndpoint::from(self.options.account()); return Err(crate::error::CosmosError::builder() @@ -2636,6 +2762,39 @@ mod tests { ) } + #[tokio::test] + async fn plan_operation_rejects_mixed_addressing() { + // The mixed name/RID guard lives in plan_operation, the single choke + // point every externally executable operation passes through. Calling + // plan_operation directly (the path queries and direct callers use, + // bypassing execute_operation) must still reject a mixed reference with + // the deterministic CLIENT_MIXED_NAME_RID_ADDRESSING error before any + // plan is built or signed. + let cosmos_runtime = CosmosDriverRuntimeBuilder::new().build().await.unwrap(); + let driver_options = DriverOptions::builder(test_account()).build(); + let driver = CosmosDriver::new(cosmos_runtime, driver_options) + .expect("CosmosDriver::new should succeed in tests"); + + // Name-addressed database parent with a RID-addressed collection leaf. + let db = DatabaseReference::from_name(test_account(), "testdb"); + let mixed = crate::models::CosmosResourceReference::from(db) + .with_resource_type(ResourceType::DocumentCollection) + .with_rid("Lx1BALxJyZ8=".into()); + let operation = CosmosOperation::new(crate::models::OperationType::Read, mixed, None); + + let err = match driver + .plan_operation(operation, &OperationOptions::default(), None) + .await + { + Ok(_) => panic!("mixed addressing must be rejected before planning"), + Err(e) => e, + }; + assert_eq!( + err.status(), + crate::error::CosmosStatus::CLIENT_MIXED_NAME_RID_ADDRESSING + ); + } + #[tokio::test] async fn default_operation_options() { let runtime = CosmosDriverRuntimeBuilder::new().build().await.unwrap(); diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/error/cosmos_status.rs b/sdk/cosmos/azure_data_cosmos_driver/src/error/cosmos_status.rs index 4af9df72f3..8df0467be2 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/error/cosmos_status.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/error/cosmos_status.rs @@ -488,6 +488,8 @@ impl SubStatusCode { 20115 => Some("ClientQueryPlanComplexProjectionUnsupported"), 20116 => Some("ClientOpaqueTokenInvalidForCrossPartitionQuery"), 20117 => Some("ClientContinuationTokenNonQueryOperation"), + 20118 => Some("ClientInvalidResourceId"), + 20119 => Some("ClientMixedNameRidAddressing"), 20150 => Some("ClientDuplicateFaultInjectionRuleId"), 20151 => Some("ClientThroughputControlGroupRegistrationFailed"), 20152 => Some("ClientThroughputControlGroupNotRegistered"), @@ -516,6 +518,7 @@ impl SubStatusCode { 20303 => Some("ServiceReturnedOfferWithoutId"), 20304 => Some("ClientThroughputPollerIncomplete"), 20305 => Some("ClientTopologyResolutionFailed"), + 20306 => Some("ServiceReturnedObjectWithoutRid"), // SDK Server-side codes (21xxx) - consistent across .NET and Java 21001 => Some("NameCacheIsStaleExceededRetryLimit"), @@ -1274,6 +1277,17 @@ impl SubStatusCode { /// operations. pub const CLIENT_CONTINUATION_TOKEN_NON_QUERY_OPERATION: SubStatusCode = SubStatusCode(20117); + /// A caller-supplied resource RID could not be parsed as a valid Cosmos DB + /// RID (20118). RIDs are Base64-encoded byte sequences; this is raised when + /// the bytes cannot be decoded or are too short to extract the expected + /// resource hierarchy. + pub const CLIENT_INVALID_RESOURCE_ID: SubStatusCode = SubStatusCode(20118); + + /// Name-based and RID-based addressing were mixed across the + /// database/container hierarchy (20119). A RID-addressed database requires a + /// RID-addressed container and vice versa. + pub const CLIENT_MIXED_NAME_RID_ADDRESSING: SubStatusCode = SubStatusCode(20119); + // ----- 20150-20199: SDK configuration / setup errors ----- /// Two fault-injection rules registered with the same id (20150). @@ -2037,6 +2051,19 @@ impl CosmosStatus { sub_status: Some(SubStatusCode::CLIENT_CONTINUATION_TOKEN_NON_QUERY_OPERATION), }; + /// 400 / 20118 — caller-supplied resource RID could not be parsed. + pub const CLIENT_INVALID_RESOURCE_ID: CosmosStatus = CosmosStatus { + status_code: StatusCode::BadRequest, + sub_status: Some(SubStatusCode::CLIENT_INVALID_RESOURCE_ID), + }; + + /// 400 / 20119 — name-based and RID-based addressing were mixed across the + /// database/container hierarchy. + pub const CLIENT_MIXED_NAME_RID_ADDRESSING: CosmosStatus = CosmosStatus { + status_code: StatusCode::BadRequest, + sub_status: Some(SubStatusCode::CLIENT_MIXED_NAME_RID_ADDRESSING), + }; + // Configuration / setup (HTTP 400, sub-status 20150-20199) /// 400 / 20150 — duplicate fault-injection rule id. @@ -2317,6 +2344,20 @@ mod tests { assert!(status.name().is_none()); } + #[test] + fn client_rid_addressing_status_names() { + // The 20118/20119 client statuses must resolve to searchable names so the + // deterministic client-side errors are useful in diagnostics and logs. + assert_eq!( + CosmosStatus::CLIENT_INVALID_RESOURCE_ID.name(), + Some("ClientInvalidResourceId") + ); + assert_eq!( + CosmosStatus::CLIENT_MIXED_NAME_RID_ADDRESSING.name(), + Some("ClientMixedNameRidAddressing") + ); + } + #[test] fn with_sub_status_unambiguous() { let status = CosmosStatus::new(StatusCode::TooManyRequests).with_sub_status(3200); diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_operation.rs b/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_operation.rs index 519ceac986..57507f3656 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_operation.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_operation.rs @@ -215,7 +215,7 @@ impl CosmosOperation { // ===== Factory Methods ===== /// Creates a new operation with the specified type, resource reference, and target. - fn new( + pub(crate) fn new( operation_type: OperationType, resource_reference: impl Into, target: Option, @@ -444,6 +444,26 @@ impl CosmosOperation { Self::new(OperationType::Read, resource_ref, None) } + /// Reads a container's properties by database and container RID. + /// + /// Like [`read_container_by_name`](Self::read_container_by_name) but addresses + /// the container by RID. Taking the raw `db_rid` and `container_rid` (rather + /// than a pre-built [`DatabaseReference`]) makes a mixed name/RID path + /// unrepresentable: the parent database reference is always constructed + /// RID-based here, so the request path is guaranteed to be fully RID-based + /// (`/dbs/{db_rid}/colls/{container_rid}`). + pub fn read_container_by_rid( + account: AccountReference, + db_rid: impl Into>, + container_rid: impl Into>, + ) -> Self { + let database = DatabaseReference::from_rid(account, db_rid.into()); + let resource_ref: CosmosResourceReference = CosmosResourceReference::from(database) + .with_resource_type(ResourceType::DocumentCollection) + .with_rid(container_rid.into()); + Self::new(OperationType::Read, resource_ref, None) + } + // ===== Data Plane Factory Methods ===== /// Creates a new item (document) in a container. diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_resource_reference.rs b/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_resource_reference.rs index 8709c6be9b..c78a7f93c6 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_resource_reference.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/models/cosmos_resource_reference.rs @@ -334,7 +334,6 @@ impl CosmosResourceReference { /// Returns whether this reference's database/container parent chain is /// RID-addressed (`Some(true)`), name-addressed (`Some(false)`), or has no /// parent chain to classify (`None`, e.g. account-level references). - #[cfg(debug_assertions)] fn parent_chain_is_rid(&self) -> Option { if let Some(ref container) = self.container { return Some(container.is_by_rid()); @@ -360,7 +359,6 @@ impl CosmosResourceReference { /// independent of the container's addressing mode), so only the database and /// container parent chain — plus a database/container leaf addressed directly /// by `id` — are checked. - #[cfg(debug_assertions)] fn addressing_conflict(&self) -> Option { if let (Some(db), Some(container)) = (self.database.as_ref(), self.container.as_ref()) { if db.is_by_rid() != container.is_by_rid() { @@ -393,12 +391,40 @@ impl CosmosResourceReference { None } + /// Validates that this reference does not mix name and RID addressing. + /// + /// This is the release-mode counterpart to + /// [`debug_assert_addressing_consistent`](Self::debug_assert_addressing_consistent): + /// the debug assert turns a mixed reference into a loud panic during tests, + /// while this returns a deterministic [`CLIENT_MIXED_NAME_RID_ADDRESSING`] + /// error in every build before the request is signed and sent — converting an + /// opaque gateway `401` into a clear client-side failure. The driver calls + /// this once per operation so the guarantee holds for references built + /// through any construction path, not just the SDK's `ContainerClient`. + /// + /// [`CLIENT_MIXED_NAME_RID_ADDRESSING`]: crate::error::CosmosStatus::CLIENT_MIXED_NAME_RID_ADDRESSING + pub(crate) fn validate_addressing(&self) -> crate::error::Result<()> { + if let Some(conflict) = self.addressing_conflict() { + return Err(crate::error::CosmosError::builder() + .with_status(crate::error::CosmosStatus::CLIENT_MIXED_NAME_RID_ADDRESSING) + .with_message(format!( + "mixed name/RID addressing is not allowed ({conflict}); a RID-addressed \ + database can only contain RID-addressed containers and vice versa" + )) + .with_source(std::io::Error::other("mixed name/RID addressing")) + .build()); + } + Ok(()) + } + /// Debug-only check that this reference does not mix name and RID addressing /// across its database/container parent chain. /// /// See [`addressing_conflict`](Self::addressing_conflict) for the exact rule /// and which leaf ids are exempt. This guard turns such a programming error - /// into a deterministic panic in debug/test builds. + /// into a deterministic panic in debug/test builds; + /// [`validate_addressing`](Self::validate_addressing) is the release-mode + /// counterpart that returns a typed error. #[cfg(debug_assertions)] fn debug_assert_addressing_consistent(&self) { if let Some(conflict) = self.addressing_conflict() { @@ -1188,6 +1214,41 @@ mod tests { let _ = r.compute_paths(); } + #[test] + fn validate_addressing_rejects_mixed_name_parent_rid_leaf() { + // Release-mode guard: the same mixed shape that panics in debug must + // return a typed CLIENT_MIXED_NAME_RID_ADDRESSING error in every build, + // so the driver fails deterministically before signing instead of + // emitting an opaque 401. + let db = DatabaseReference::from_name(test_account(), "testdb"); + let r = CosmosResourceReference::from(db) + .with_resource_type(ResourceType::DocumentCollection) + .with_rid("Lx1BALxJyZ8=".into()); + let err = r + .validate_addressing() + .expect_err("mixed addressing must be rejected"); + assert_eq!( + err.status(), + crate::error::CosmosStatus::CLIENT_MIXED_NAME_RID_ADDRESSING + ); + } + + #[test] + fn validate_addressing_accepts_consistent_addressing() { + // A fully name-addressed and a fully RID-addressed reference are both + // consistent, so validation is a no-op (returns Ok) and the flow is + // unchanged. + let name_ref: CosmosResourceReference = test_container().into(); + name_ref + .validate_addressing() + .expect("name addressing is consistent"); + + let rid_ref: CosmosResourceReference = test_container_by_rid().into(); + rid_ref + .validate_addressing() + .expect("RID addressing is consistent"); + } + #[test] fn container_by_name_signs_full_link() { let r: CosmosResourceReference = test_container().into(); diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/models/resource_reference.rs b/sdk/cosmos/azure_data_cosmos_driver/src/models/resource_reference.rs index a3245cf1a1..03c0934695 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/models/resource_reference.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/models/resource_reference.rs @@ -224,11 +224,9 @@ impl ContainerReference { /// own name is still recorded because the service returns it in the container /// read response. /// - /// Not exposed publicly — used by the driver's RID-based container - /// resolution to obtain a RID-addressed container reference. - // Constructed only by unit tests in this slice; the RID-based resolution path - // that calls it lands in a follow-up change. - #[allow(dead_code)] + /// Not exposed publicly — use + /// [`CosmosDriver::resolve_container_by_rid()`](crate::driver::CosmosDriver::resolve_container_by_rid) + /// to obtain a RID-addressed container reference. pub(crate) fn new_by_rid( account: AccountReference, db_rid: impl Into,