Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Distributed operations (4.3.6, 5.12 matching, 6.3.17–6.3.19).
3 : //!
4 : //! Registration modes: inclusive (local + forward), auxiliary (read-only
5 : //! supplement, local wins), exclusive/redirect (proxied — registered data is
6 : //! never held locally). Forwarded requests carry `Via: 1.1 <hostAlias>` and
7 : //! the request @context as a Link header; bodies travel as application/json
8 : //! without an inline @context.
9 : //!
10 : //! Three contracts hold across this module and `csource.rs`: candidate
11 : //! matching is SQL over `csource_index`, never a scan of a tenant's
12 : //! registrations; a forwarded query is narrowed to the registration's scope
13 : //! (4.3.6.1 — spec-mandated, not a bug to be fixed away); and fan-out is
14 : //! bounded, by a semaphore, a per-source timeout and an aggregate deadline.
15 :
16 : use crate::negotiate::*;
17 : use crate::paging::pct_encode;
18 : use crate::state::AppState;
19 : use antares_jsonld::Context;
20 : use antares_model::operations::{group_members, DEFAULT_OPERATION_GROUP};
21 : use antares_model::{NgsiError, TenantId};
22 : #[cfg(test)]
23 : use antares_store::Kind;
24 : use axum::http::{HeaderMap, StatusCode};
25 : use axum::response::{IntoResponse, Response};
26 : use serde_json::{json, Map, Value};
27 : use std::collections::HashMap;
28 :
29 : /// One matching registration, compiled for forwarding.
30 : #[derive(Clone, Debug, Default)]
31 : pub struct FedReg {
32 : /// The registration's @id — carried into `NotUpdatedDetails.registrationId`
33 : /// (5.2.19) and `BatchEntityError.registrationId` (5.2.17).
34 : pub reg_id: String,
35 : pub endpoint: String,
36 : pub mode: String, // inclusive | auxiliary | exclusive | redirect
37 : pub(crate) ops: Vec<String>,
38 : /// Expanded attribute IRIs the matched RegistrationInfo covers; None ⇒ all.
39 : pub attrs: Option<Vec<String>>,
40 : /// EntityInfo ids/types of the matched RegistrationInfo elements.
41 : pub ent_ids: Vec<String>,
42 : pub ent_types: Vec<String>,
43 : /// EntityInfo idPattern values (5.2.8 IEEE 1003.2 regexes).
44 : pub ent_patterns: Vec<String>,
45 : /// True when any matched RegistrationInfo carries an EntityInfo with
46 : /// neither id nor idPattern (or no entities at all) — the registration
47 : /// imposes no id restriction (5.12 condition 1).
48 : pub ent_unrestricted: bool,
49 : /// 5.2.9 `tenant`: the Tenant to specify in all requests to this Context
50 : /// Source. None ⇒ the requesting tenant is carried through unchanged.
51 : pub tenant: Option<String>,
52 : /// 5.2.9 `contextSourceAlias`: "a previously retrieved unique id for a
53 : /// registered Context Source which is used to identify loops", tenant-
54 : /// specific per Table 5.2.9-1. A registration whose alias is already in
55 : /// the inbound Via chain names a source this request has visited, so it
56 : /// is not a matching registration (Table 6.3.18-2).
57 : pub alias: Option<String>,
58 : /// 4.3.6.5 `contextSourceInfo` key/value pairs, conveyed as headers on
59 : /// every forward to this source (string values only — headers).
60 : pub csi: Vec<(String, String)>,
61 : /// 5.2.9 `localOnly` (4.3.6.4): distributed operations for this
62 : /// registration "will act only on data held directly by the registered
63 : /// Context Source itself" — every forward carries `local=true`.
64 : pub local_only: bool,
65 : /// 5.2.34 timeout: "Maximum period of time in milliseconds which may
66 : /// elapse before a forwarded request is assumed to have failed."
67 : pub timeout_ms: Option<u64>,
68 : /// 5.2.34 cooldown: "Minimum period of time in milliseconds which shall
69 : /// elapse before attempting to make a subsequent forwarded request to
70 : /// the same endpoint after failure."
71 : pub cooldown_ms: Option<u64>,
72 : }
73 :
74 : impl FedReg {
75 : /// 4.3.6.1: registered Context Sources "may indicate that they are only
76 : /// willing to respond to a limited subset of API operations. Context
77 : /// Brokers shall respect this, to avoid unnecessarily sending distributed
78 : /// operation requests which are always guaranteed to fail." Matches the
79 : /// registration's `operations` list (5.2.9) by name or operation group;
80 : /// default when absent is federationOps.
81 80874 : pub fn supports(&self, op: &str) -> bool {
82 80874 : self.ops
83 80874 : .iter()
84 120406 : .any(|o| o == op || group_members(o).is_some_and(|members| members.contains(&op)))
85 80874 : }
86 1802 : pub fn is_proxy(&self) -> bool {
87 1802 : self.mode == "exclusive" || self.mode == "redirect"
88 1802 : }
89 : /// 4.3.6.1 ("all constraints specified in the registration shall be
90 : /// respected" — including Entity IDs): can this registration's
91 : /// EntityInfo id constraints match `id`? Patterns use regex find,
92 : /// mirroring `entity_info_matches` (5.12).
93 1578 : pub fn can_match_id(&self, id: &str) -> bool {
94 1578 : self.ent_unrestricted
95 948 : || self.ent_ids.iter().any(|i| i == id)
96 484 : || self
97 484 : .ent_patterns
98 484 : .iter()
99 484 : .any(|p| antares_ql::regex::compile(p).is_ok_and(|re| re.find(id).is_some()))
100 1578 : }
101 : /// Does this registration cover the given expanded attribute IRI?
102 1050 : pub fn covers_attr(&self, iri: &str) -> bool {
103 1050 : self.attrs
104 1050 : .as_ref()
105 1050 : .is_none_or(|a| a.iter().any(|x| x == iri))
106 1050 : }
107 368 : pub fn read_op(&self) -> Option<&'static str> {
108 368 : ["retrieveEntity", "queryEntity", "queryBatch"]
109 368 : .into_iter()
110 416 : .find(|op| self.supports(op))
111 368 : }
112 : /// 4.20 Table 4.20-1: queryEntity/queryBatch implement 5.7.2 Query
113 : /// Entities — retrieveEntity implements only 5.7.1, so a query is never
114 : /// forwarded to a source that offers retrieveEntity alone (4.3.6.1:
115 : /// "Context Brokers shall respect this").
116 718 : pub fn query_op(&self) -> Option<&'static str> {
117 718 : ["queryEntity", "queryBatch"]
118 718 : .into_iter()
119 770 : .find(|op| self.supports(op))
120 718 : }
121 : /// 4.3.6.1: the registration's EntityInfo constraints gate which payload
122 : /// ITEMS a distributed write may carry — an item whose present id/type
123 : /// the registration does not name is not this source's data. An item
124 : /// without a `type` member (attribute fragments) cannot be disproven and
125 : /// stays covered.
126 826 : pub fn covers_item(&self, obj: &Map<String, Value>, ctx: &Context) -> bool {
127 826 : if !self.ent_ids.is_empty() || !self.ent_patterns.is_empty() {
128 722 : if let Some(id) = obj.get("id").and_then(Value::as_str) {
129 622 : if !self.can_match_id(id) {
130 4 : return false;
131 618 : }
132 100 : }
133 104 : }
134 822 : self.covers_types(obj, ctx)
135 826 : }
136 : /// The type half of `covers_item`: does the item's `type` name one of the
137 : /// registration's EntityInfo types? No type to check ⇒ covered.
138 1386 : pub fn covers_types(&self, obj: &Map<String, Value>, ctx: &Context) -> bool {
139 1386 : if !self.ent_types.is_empty() {
140 1362 : let types: Vec<String> = match obj.get("type") {
141 1268 : Some(Value::String(t)) => vec![ctx.expand_key(t)],
142 4 : Some(Value::Array(a)) => a
143 4 : .iter()
144 4 : .filter_map(Value::as_str)
145 4 : .map(|t| ctx.expand_key(t))
146 4 : .collect(),
147 90 : _ => Vec::new(),
148 : };
149 1362 : if !types.is_empty() && !types.iter().any(|t| self.ent_types.contains(t)) {
150 22 : return false;
151 1340 : }
152 24 : }
153 1364 : true
154 1386 : }
155 : }
156 :
157 : /// Is federation active for this request? (6.3.18 local param; 5.5.13:
158 : /// a local-scope request executes only on information available locally —
159 : /// no Context Source Registrations are considered)
160 : ///
161 : /// Table 6.4.3.2-1: for `type=*`, "local is implicitly set to true and shall
162 : /// not be explicitly set to false" — so the wildcard alone disables forwarding.
163 45809 : pub fn active(params: &HashMap<String, String>) -> bool {
164 45809 : if params.get("type").map(String::as_str) == Some("*") {
165 32 : return false;
166 45777 : }
167 45777 : params.get("local").map(String::as_str) != Some("true")
168 45809 : }
169 :
170 : /// The full inbound Via chain, joined across header FIELDS.
171 : ///
172 : /// RFC 7230 sections 3.2.2 and 5.7.1: Via is a list header — senders may split the
173 : /// chain over any number of `Via:` field lines, and the two forms are
174 : /// equivalent. Reading only the first field (`headers.get`) made a loop
175 : /// pseudonym in a later field invisible (undetected cycle) AND rebuilt the
176 : /// outbound chain from the truncated view, deleting upstream hop history
177 : /// that downstream brokers need for THEIR loop detection.
178 89107 : pub fn inbound_via(headers: &HeaderMap) -> Option<String> {
179 89107 : let fields: Vec<&str> = headers
180 89107 : .get_all("via")
181 89107 : .iter()
182 89107 : .filter_map(|v| v.to_str().ok())
183 89107 : .collect();
184 89107 : if fields.is_empty() {
185 88747 : None
186 : } else {
187 360 : Some(fields.join(", "))
188 : }
189 89107 : }
190 :
191 : /// This broker's Via pseudonym **for one Tenant**.
192 : ///
193 : /// Table 5.2.40-1: the alias is "a unique id for a Context Source which can
194 : /// be used to identify loops. In the multi-tenancy use case (see clause
195 : /// 4.14), this id **shall** be identifying a specific Tenant within a
196 : /// registered Context Source." One static per-process alias therefore makes
197 : /// every tenant of this broker look like the same Context Source: a request
198 : /// in tenant B whose registration points back here for tenant A is a
199 : /// different (source, tenant) pair, but a tenant-blind chain reads it as a
200 : /// loop and drops the registration.
201 : ///
202 : /// Format `{alias}~{tenant}`, and the bare alias for the default tenant —
203 : /// mirroring 6.3.14, where the tenant header is omitted rather than sent as
204 : /// `default`. `~` is an RFC 7230 token character that cannot occur in a
205 : /// `TenantId` (`[A-Za-z0-9_-]{1,64}`), so the two halves never blur; the
206 : /// broker rejects a configured `ANTARES_HOST_ALIAS` containing `~` at
207 : /// startup, so `a~b` in the default tenant cannot collide with `a` in
208 : /// tenant `b`.
209 : ///
210 : /// The value is stable for the life of a deployment because peers **register**
211 : /// it: a Context Source Registration's `contextSourceAlias` (Table 5.2.9-1) is
212 : /// "a previously retrieved unique id" — retrieved from this broker's
213 : /// `/info/sourceIdentity` for that tenant. Changing an alias silently breaks
214 : /// every peer's loop detection, so treat it as a published identifier.
215 44920 : pub fn alias_for(host_alias: &str, tenant: &TenantId) -> String {
216 44920 : if tenant.as_str() == TenantId::DEFAULT {
217 43564 : host_alias.to_owned()
218 : } else {
219 1356 : format!("{host_alias}~{}", tenant.as_str())
220 : }
221 44920 : }
222 :
223 : /// The `received-by` pseudonyms of the inbound Via chain — the Context
224 : /// Sources this request has already passed through (Table 6.3.18-2).
225 : ///
226 : /// RFC 7230: `Via = 1#( received-protocol RWS received-by [ RWS comment ] )`
227 : /// — received-by is the SECOND whitespace token of each element and is a
228 : /// token compared for equality, never by suffix (`ends_with`
229 : /// made alias `b1` match peer `sub-b1`). A malformed element with no
230 : /// protocol falls back to its first token.
231 86503 : pub fn via_tokens(headers: &HeaderMap) -> Vec<String> {
232 86503 : inbound_via(headers)
233 86503 : .map(|v| {
234 308 : v.split(',')
235 956 : .filter_map(|t| {
236 956 : let mut toks = t.split_whitespace();
237 956 : let first = toks.next();
238 956 : toks.next().or(first).map(str::to_owned)
239 956 : })
240 308 : .collect()
241 308 : })
242 86503 : .unwrap_or_default()
243 86503 : }
244 :
245 : /// How many `Via` elements this broker will process on one inbound request.
246 : ///
247 : /// 6.3.18 fixes the header's purpose ("to avoid infinite loops") and Table
248 : /// 6.3.18-2 makes its listing part of registration matching, so every
249 : /// element is compared against every candidate registration: the chain is
250 : /// attacker-supplied input whose length multiplies the work of the request.
251 : /// RFC 7230 section 3.2.5 lets a recipient refuse a field longer than it is
252 : /// willing to process. A cascade deeper than this is a loop the alias
253 : /// pseudonyms failed to name, not a deployment.
254 : const MAX_VIA_HOPS: usize = 32;
255 :
256 : /// The number of `Via` elements received, counted across header fields
257 : /// without building the token list (RFC 7230 sections 3.2.2 and 5.7.1: the
258 : /// elements of a list header may be split over any number of field lines).
259 86431 : fn via_hops(headers: &HeaderMap) -> usize {
260 86431 : headers
261 86431 : .get_all("via")
262 86431 : .iter()
263 86431 : .filter_map(|v| v.to_str().ok())
264 86431 : .map(|v| v.split(',').count())
265 86431 : .sum()
266 86431 : }
267 :
268 : /// Does the inbound Via chain already name this broker, in this tenant?
269 : /// (loop, 6.3.18) — `alias` is always [`alias_for`]'s tenant-qualified value.
270 43328 : pub fn via_loop(headers: &HeaderMap, alias: &str) -> bool {
271 43328 : via_hops(headers) > MAX_VIA_HOPS || via_tokens(headers).iter().any(|t| t == alias)
272 43328 : }
273 :
274 : /// Loop handling for a distributed write (6.3.17, 6.3.18, TS 104 175 9.7).
275 : /// 9.7: "If the requested endpoint is also listed as previously encountered
276 : /// when a Context Source receives a call to that endpoint, this is an
277 : /// indication that a new loop has been detected. A suitable error shall be
278 : /// returned". A write whose `Via`
279 : /// already names this broker in this tenant is answered 508 Loop Detected
280 : /// whatever its registrations, the single exclusive or redirect source of
281 : /// TS 104 176 6.3.5 included, and nothing is forwarded; a read with the same
282 : /// chain is served locally with a 199 warning by its own handler.
283 35916 : pub fn handle_via_loop(
284 35916 : headers: &HeaderMap,
285 35916 : alias: &str,
286 35916 : tenant: &TenantId,
287 35916 : regs: &mut Vec<FedReg>,
288 35916 : ) -> Option<Response> {
289 : // [`via_loop`] also refuses a chain past [`MAX_VIA_HOPS`] outright: the
290 : // operation is not re-forwarded and not run locally either, because a
291 : // request that deep is a cascade the pseudonyms failed to close.
292 35916 : if !via_loop(headers, alias) {
293 35846 : return None;
294 70 : }
295 70 : regs.clear();
296 70 : Some(loop_508(tenant))
297 35916 : }
298 :
299 : /// One `NGSILD-Warning` header value (6.3.17), in RFC 7234 warn form:
300 : /// `warn-code SP warn-agent SP quoted warn-text`.
301 208 : pub fn warning(code: u16, alias: &str, text: &str) -> String {
302 208 : format!("{code} {alias} \"{text}\"")
303 208 : }
304 :
305 : /// Classify one forwarded-read outcome per Table 6.3.17-1. A registration
306 : /// endpoint answering 404 with no data "should not be considered as abnormal
307 : /// behaviour"; 503/504 means no response arrived within the timeout (199);
308 : /// any other error status IS a received error response (299); a 2xx whose
309 : /// payload could not be parsed as NGSI-LD is 111.
310 556 : fn read_warning(status: u16, body: &Value) -> Option<(u16, &'static str)> {
311 398 : match status {
312 6 : 404 => None,
313 138 : 503 | 504 => Some((
314 138 : 199,
315 138 : "no response was received from the registration endpoint within the timeout period",
316 138 : )),
317 412 : s if s >= 400 => Some((
318 14 : 299,
319 14 : "an error response was received from the registration endpoint",
320 14 : )),
321 398 : s if (200..300).contains(&s) && body.is_null() => {
322 26 : Some((111, "the payload of the response was invalid"))
323 : }
324 372 : _ => None,
325 : }
326 556 : }
327 :
328 1396 : fn outbound_via(headers: &HeaderMap, alias: &str) -> String {
329 1396 : match inbound_via(headers) {
330 28 : Some(v) => format!("{v}, 1.1 {alias}"),
331 1368 : None => format!("1.1 {alias}"),
332 : }
333 1396 : }
334 :
335 : /// Percent-encode one client-controlled value for use as a single path
336 : /// segment of a forwarded URL (RFC 3986 clause 3.3: a segment is made of
337 : /// `pchar`, and `/`, `?`, `#` end it). Entity ids and attribute names arrive
338 : /// already percent-decoded from the request path, so splicing them raw would
339 : /// let `#` or `?` truncate the forwarded path and re-target the peer's
340 : /// resource — `.../entities/urn:x%23/attrs/speed` would reach the peer as
341 : /// Delete Entity (5.6.6) instead of Delete Attribute (5.6.5).
342 15230 : pub(crate) fn path_segment(s: &str) -> String {
343 63032 : pct_encode(s, |b| {
344 148 : matches!(
345 63032 : b,
346 : b'-' | b'.'
347 : | b'_'
348 : | b'~'
349 : | b'!'
350 : | b'$'
351 : | b'&'
352 : | b'\''
353 : | b'('
354 : | b')'
355 : | b'*'
356 : | b'+'
357 : | b','
358 : | b';'
359 : | b'='
360 : | b':'
361 : | b'@'
362 : )
363 63032 : })
364 15230 : }
365 :
366 : /// The @context URL to advertise on forwarded requests.
367 7461 : pub fn ctx_link_url(headers: &HeaderMap, source: &Value) -> String {
368 : // the request was validated on the way in, so an ambiguous Link is
369 : // already a 400 and cannot reach a forward
370 7461 : if let Ok(Some(url)) = link_context(headers) {
371 2410 : return url;
372 5051 : }
373 176 : match source {
374 4857 : Value::String(s) => s.clone(),
375 : // 5.5.7/6.3.5 fidelity: an inline @context has no dereferenceable
376 : // URL — serialize it so forward() can embed it in the body as
377 : // application/ld+json instead of dropping the term mappings.
378 176 : Value::Array(a) if a.iter().any(|e| !e.is_string()) => {
379 0 : serde_json::to_string(source).unwrap_or_else(|_| antares_jsonld::CORE_CONTEXT.into())
380 : }
381 176 : Value::Array(a) => a
382 176 : .iter()
383 176 : .find_map(|e| e.as_str())
384 176 : .unwrap_or(antares_jsonld::CORE_CONTEXT)
385 176 : .to_owned(),
386 : Value::Object(_) => {
387 18 : serde_json::to_string(source).unwrap_or_else(|_| antares_jsonld::CORE_CONTEXT.into())
388 : }
389 0 : _ => antares_jsonld::CORE_CONTEXT.to_owned(),
390 : }
391 7461 : }
392 :
393 : /// The registration documents of one tenant: the ONE compiled mirror when
394 : /// wired (bus=nats), the store otherwise — narrowed there by the ids and
395 : /// Entity Types this operation names, so a broker holding a large
396 : /// registration set does not read all of it per distributed request.
397 : ///
398 : /// A store that cannot answer is an error, never an empty set: "no Context
399 : /// Source is registered" and "the registrations could not be read" lead to
400 : /// opposite answers, and only the first of them is one the client may be
401 : /// shown as complete (Table 6.3.2-1 InternalError).
402 : ///
403 : /// The narrowing may only ever drop registrations [`reg_candidate`] would
404 : /// reject anyway; it is a prefilter, never the decision. So the type
405 : /// dimension is dropped whenever a member is a 4.17 Entity Type Selection
406 : /// (`A|B`, `(A;B)`, `*`) rather than a single type: the index compares types
407 : /// by equality and cannot evaluate a selection, and a narrowing that
408 : /// mis-decides would silently lose a Context Source. A plain term is
409 : /// expanded first — the spec carries the parameter as the client wrote it
410 : /// (`Vehicle`), the index stores what [`reg_candidate`] compares: the IRI.
411 43085 : async fn reg_docs(
412 43085 : st: &AppState,
413 43085 : tenant: &TenantId,
414 43085 : spec: &crate::registry::CsrSpec,
415 43085 : ctx: &Context,
416 43085 : ) -> Result<Vec<std::sync::Arc<Value>>, NgsiError> {
417 43085 : let types: Option<Vec<String>> = spec
418 43085 : .types
419 43085 : .as_ref()
420 43085 : .filter(|ts| {
421 17027 : !ts.iter()
422 22663 : .any(|t| t.contains([',', ';', '|', '(', ')', '*']))
423 17027 : })
424 43085 : .map(|ts| ts.iter().map(|t| ctx.expand_key(t)).collect());
425 : // The id dimension is dropped whenever the query ALSO carries an
426 : // idPattern: `entity_info_matches` lets a registration's own entity id
427 : // match that pattern, so narrowing to the id list alone would drop a
428 : // Context Source 5.12 matches — the one thing a prefilter may never do.
429 43085 : let ids = spec.ids.as_deref().filter(|_| spec.id_pattern.is_none());
430 43085 : let mut docs: Vec<std::sync::Arc<Value>> = match &st.reg_mirror {
431 38246 : Some(m) => m.matching(tenant.as_str(), ids, types.as_deref()),
432 4839 : None => st
433 4839 : .store
434 4839 : .matching_registrations(tenant, ids, types.as_deref())
435 4839 : .await?
436 4831 : .into_iter()
437 4831 : .map(std::sync::Arc::new)
438 4831 : .collect(),
439 : };
440 : // The mirror answers in hash order, Postgres in its collation's id order
441 : // and the memory store by its own keys: one order for all of them (id,
442 : // bytewise) keeps forwards, 207 reasons and the first failing
443 : // registration the same whichever one answered.
444 43077 : docs.sort_by(|a, b| doc_id(a).cmp(doc_id(b)));
445 43077 : Ok(docs)
446 43085 : }
447 :
448 61684 : fn doc_id(doc: &Value) -> &str {
449 61684 : doc.get("id").and_then(Value::as_str).unwrap_or_default()
450 61684 : }
451 :
452 : /// Does one stored registration take part in this operation, and through
453 : /// which RegistrationInfos (5.12)? Every condition is decided from the
454 : /// borrowed document — expiry, csf, datasetId, location, intervals, the Via
455 : /// chain — so a caller that only needs the verdict ([`would_federate`])
456 : /// stops here instead of compiling a `FedReg` per registration. Expiry is
457 : /// filtered HERE and only here: the single yield point.
458 32594 : fn reg_candidate<'a>(
459 32594 : doc: &'a Value,
460 32594 : spec: &crate::registry::CsrSpec,
461 32594 : ctx: &Context,
462 32594 : seen: &[String],
463 32594 : ) -> Option<Vec<&'a Value>> {
464 32594 : if crate::registry::reg_expired(doc) {
465 0 : return None;
466 32594 : }
467 : // 5.7.2.4/5.7.4.4/5.6.21.4: a csf gates which Context Sources
468 : // are considered (evaluated over the registration's own
469 : // Context Source Properties, 5.10.2.4 semantics).
470 32594 : if let Some(csf) = &spec.csf {
471 14 : if !crate::registry::csf_matches(csf, doc, ctx) {
472 8 : return None;
473 6 : }
474 32580 : }
475 : // 5.12 datasetId condition (should-level): both sides specifying
476 : // datasetId match only with a value in common; one side alone always
477 : // matches.
478 32586 : if let Some(ds) = &spec.dataset_ids {
479 4 : if let Some(reg_ds) = doc.get("datasetId").and_then(Value::as_array) {
480 4 : if !reg_ds
481 4 : .iter()
482 4 : .filter_map(Value::as_str)
483 4 : .any(|d| ds.iter().any(|q| q == d))
484 : {
485 2 : return None;
486 2 : }
487 0 : }
488 32582 : }
489 : // 5.2.9 location + 4.3.6.1: a geo-scoped registration is only consulted
490 : // when the query's geo filter matches its geometry; a registration
491 : // without `location` is unconstrained.
492 32584 : if let Some(gq) = &spec.geo {
493 4 : if let Some(geom) = doc.get("location") {
494 0 : if !gq.matches_geometry(geom) {
495 0 : return None;
496 0 : }
497 4 : }
498 32580 : }
499 : // 5.2.9: a declared observation/management interval gates temporal
500 : // fan-out on overlap with the temporal query; without any declared
501 : // interval the registration is unconstrained.
502 32584 : if let Some(tq) = &spec.temporal {
503 30 : if (doc.get("observationInterval").is_some() || doc.get("managementInterval").is_some())
504 0 : && !crate::registry::temporal_interval_matches(doc, tq)
505 : {
506 0 : return None;
507 30 : }
508 32554 : }
509 : // Table 6.3.18-2 / 5.2.9: this source already handled the request.
510 32584 : if doc
511 32584 : .get("contextSourceAlias")
512 32584 : .and_then(Value::as_str)
513 32584 : .is_some_and(|a| seen.iter().any(|t| t == a))
514 : {
515 14 : return None;
516 32570 : }
517 32570 : let infos = crate::registry::matching_infos(spec, doc, ctx);
518 32570 : if infos.is_empty() {
519 30770 : None
520 : } else {
521 1800 : Some(infos)
522 : }
523 32594 : }
524 :
525 : /// Registrations matching an entity spec (5.12), compiled for forwarding.
526 : ///
527 : /// Table 6.3.18-2 makes the inbound `Via` listing part of matching itself —
528 : /// "the listing of previously encountered Context Sources supplied is used
529 : /// when determining matching registrations" — so a registration whose
530 : /// `contextSourceAlias` is already in the chain is filtered out HERE, at the
531 : /// one place every read and write path resolves its candidates. Keeping it
532 : /// out of the call sites is deliberate: a loop check the callers own is a
533 : /// loop check some caller forgets.
534 42217 : pub async fn matching_regs(
535 42217 : st: &AppState,
536 42217 : tenant: &TenantId,
537 42217 : spec: &crate::registry::CsrSpec,
538 42217 : ctx: &Context,
539 42217 : headers: &HeaderMap,
540 42217 : ) -> Result<Vec<FedReg>, NgsiError> {
541 42217 : if via_hops(headers) > MAX_VIA_HOPS {
542 18 : return Ok(Vec::new());
543 42199 : }
544 42199 : let seen = via_tokens(headers);
545 42199 : let regs: Vec<FedReg> = reg_docs(st, tenant, spec, ctx)
546 42199 : .await?
547 42191 : .iter()
548 42538 : .filter_map(|doc| {
549 32550 : let infos = reg_candidate(doc, spec, ctx, &seen)?;
550 1764 : let alias = doc
551 1764 : .get("contextSourceAlias")
552 1764 : .and_then(Value::as_str)
553 1764 : .map(str::to_owned);
554 1764 : let endpoint = endpoint_base(doc.get("endpoint").and_then(Value::as_str)?);
555 1764 : let mode = doc
556 1764 : .get("mode")
557 1764 : .and_then(Value::as_str)
558 1764 : .unwrap_or("inclusive")
559 1764 : .to_owned();
560 1764 : let ops = doc
561 1764 : .get("operations")
562 1764 : .and_then(Value::as_array)
563 1764 : .map(|a| {
564 1522 : a.iter()
565 1522 : .filter_map(Value::as_str)
566 1522 : .map(str::to_owned)
567 1522 : .collect()
568 1522 : })
569 1764 : .unwrap_or_else(|| vec![DEFAULT_OPERATION_GROUP.into()]);
570 1764 : let mut attrs: Option<Vec<String>> = Some(Vec::new());
571 1764 : let mut ent_ids = Vec::new();
572 1764 : let mut ent_types = Vec::new();
573 1764 : let mut ent_patterns = Vec::new();
574 1764 : let mut ent_unrestricted = false;
575 : // an info that names no type admits every type; ent_types empty
576 : // is how every reader of it spells "no type restriction"
577 1764 : let mut any_type = false;
578 1766 : for info in &infos {
579 : // TS 104 175 9.4.2: attributeNames, else the deprecated pair
580 1766 : match antares_model::registration_attribute_names(info) {
581 1504 : None => attrs = None, // an unscoped info covers everything
582 262 : Some(names) => {
583 262 : if let Some(list) = &mut attrs {
584 260 : list.extend(names.into_iter().map(str::to_owned));
585 260 : }
586 : }
587 : }
588 1766 : if let Some(es) = info.get("entities").and_then(Value::as_array) {
589 1812 : for e in es {
590 1812 : if let Some(i) = e.get("id").and_then(Value::as_str) {
591 858 : ent_ids.push(i.to_owned());
592 962 : }
593 1812 : if let Some(p) = e.get("idPattern").and_then(Value::as_str) {
594 310 : ent_patterns.push(p.to_owned());
595 1502 : }
596 : // 5.12 condition 1: neither id nor idPattern ⇒ the
597 : // element restricts by type only, never by id
598 1812 : if e.get("id").is_none() && e.get("idPattern").is_none() {
599 644 : ent_unrestricted = true;
600 1168 : }
601 : // 5.2.8: type may be a String or String[]
602 1812 : match e.get("type") {
603 1800 : Some(Value::String(t)) => ent_types.push(t.clone()),
604 0 : Some(Value::Array(ts)) => ent_types
605 0 : .extend(ts.iter().filter_map(Value::as_str).map(str::to_owned)),
606 12 : _ => any_type = true,
607 : }
608 : }
609 18 : } else {
610 18 : // an attributes-only RegistrationInfo imposes no id scope
611 18 : ent_unrestricted = true;
612 18 : any_type = true;
613 18 : }
614 : }
615 1764 : if any_type {
616 30 : ent_types.clear();
617 1734 : }
618 1764 : let tenant = doc.get("tenant").and_then(Value::as_str).map(str::to_owned);
619 1764 : let csi = csi_of(doc);
620 : Some(FedReg {
621 1764 : reg_id: doc
622 1764 : .get("id")
623 1764 : .and_then(Value::as_str)
624 1764 : .unwrap_or_default()
625 1764 : .to_owned(),
626 1764 : endpoint,
627 1764 : mode,
628 1764 : ops,
629 1764 : attrs,
630 1764 : ent_ids,
631 1764 : ent_types,
632 1764 : ent_patterns,
633 1764 : ent_unrestricted,
634 1764 : tenant,
635 1764 : alias,
636 1764 : csi,
637 1764 : local_only: local_only_of(doc),
638 1764 : timeout_ms: doc
639 1764 : .get("management")
640 1764 : .and_then(|m| m.get("timeout"))
641 1764 : .and_then(Value::as_u64),
642 1764 : cooldown_ms: doc
643 1764 : .get("management")
644 1764 : .and_then(|m| m.get("cooldown"))
645 1764 : .and_then(Value::as_u64),
646 : })
647 32550 : })
648 42191 : .collect();
649 42191 : Ok(merge_same_source(regs))
650 42217 : }
651 :
652 : /// The API operations a registration is willing to answer, EXPANDED: 5.2.9
653 : /// lets `operations` name groups as well as operations, so `["federationOps"]`
654 : /// and the names it stands for are the same subset written two ways, and only
655 : /// the expansion can tell two registrations apart by what they will serve.
656 1824 : fn op_set(r: &FedReg) -> std::collections::BTreeSet<&'static str> {
657 1824 : antares_model::operations::OPERATION_NAMES
658 1824 : .iter()
659 1824 : .copied()
660 78432 : .filter(|op| r.supports(op))
661 1824 : .collect()
662 1824 : }
663 :
664 14 : fn same_entity_scope(a: &FedReg, b: &FedReg) -> bool {
665 14 : a.ent_ids == b.ent_ids
666 14 : && a.ent_types == b.ent_types
667 4 : && a.ent_patterns == b.ent_patterns
668 4 : && a.ent_unrestricted == b.ent_unrestricted
669 14 : }
670 :
671 20 : fn covers_everything(r: &FedReg) -> bool {
672 20 : r.attrs.is_none()
673 10 : && r.ent_types.is_empty()
674 4 : && r.ent_ids.is_empty()
675 4 : && r.ent_patterns.is_empty()
676 4 : && r.ent_unrestricted
677 20 : }
678 :
679 : /// Registrations naming the same Context Source (same endpoint, mode,
680 : /// tenant, contextSourceAlias, contextSourceInfo, localOnly) fold into ONE
681 : /// forwarded request (5.2.9: the alias identifies a source, so a different
682 : /// alias is a different source even behind one endpoint):
683 : /// attribute and entity scopes union (an unscoped one covers everything),
684 : /// the first registration's id and timing stay. Two calls to one source for
685 : /// one query would return the same data twice.
686 : ///
687 : /// Registrations that declare DIFFERENT operations are not the same source
688 : /// for this purpose. 4.3.6.1: a source "may indicate that they are only
689 : /// willing to respond to a limited subset of API operations. Context Brokers
690 : /// shall respect this" — and the fold unions the entity and attribute scope,
691 : /// so an operation only one of them declared would then travel for the
692 : /// other's Entities. They stay separate instead, each filtered by `supports`
693 : /// on its own. The cost is one extra request to a source that registered
694 : /// itself twice with different operation lists and both lists cover the
695 : /// operation at hand; 4.5.5 merges the two answers by Entity id, so the
696 : /// caller sees the same data either way.
697 : ///
698 : /// For the same reason they fold only when the union is exact: (Vehicle ×
699 : /// all) and (OffStreetParking × two attributes) would fold to
700 : /// (both types × all), and the source's whole OffStreetParking would be
701 : /// imported (ETSI IOP_003_02_02). Equal attribute scopes, equal entity
702 : /// scopes, or one side covering everything keep the union exact.
703 42219 : fn merge_same_source(regs: Vec<FedReg>) -> Vec<FedReg> {
704 42219 : let mut out: Vec<(FedReg, std::collections::BTreeSet<&'static str>)> = Vec::new();
705 42219 : for r in regs {
706 1824 : let ops = op_set(&r);
707 1824 : let same = out.iter_mut().find(|(o, o_ops)| {
708 792 : o.endpoint == r.endpoint
709 66 : && o.mode == r.mode
710 58 : && o.tenant == r.tenant
711 58 : && o.alias == r.alias
712 42 : && o.csi == r.csi
713 42 : && o.local_only == r.local_only
714 38 : && *o_ops == ops
715 34 : && (o.attrs == r.attrs
716 14 : || same_entity_scope(o, &r)
717 10 : || covers_everything(o)
718 10 : || covers_everything(&r))
719 792 : });
720 1824 : let Some((o, _)) = same else {
721 1796 : out.push((r, ops));
722 1796 : continue;
723 : };
724 28 : o.attrs = match (o.attrs.take(), r.attrs) {
725 8 : (Some(mut a), Some(b)) => {
726 12 : for x in b {
727 12 : if !a.contains(&x) {
728 4 : a.push(x);
729 8 : }
730 : }
731 8 : Some(a)
732 : }
733 20 : _ => None,
734 : };
735 28 : o.ent_ids.extend(r.ent_ids);
736 28 : if o.ent_types.is_empty() || r.ent_types.is_empty() {
737 4 : o.ent_types.clear(); // either side admits every type
738 24 : } else {
739 24 : o.ent_types.extend(r.ent_types);
740 24 : }
741 28 : o.ent_patterns.extend(r.ent_patterns);
742 28 : o.ent_unrestricted |= r.ent_unrestricted;
743 : }
744 42219 : out.into_iter().map(|(r, _)| r).collect()
745 42219 : }
746 :
747 : /// contextSourceInfo keys the forward must NOT copy into headers: the tenant
748 : /// travels via the registration's own `tenant` member (4.3.6.5 "shall not be
749 : /// part of contextSourceInfo"), connection/binding-managed headers cannot be
750 : /// overridden ("shall be ignored"), and the 4.3.6.6 processed keys (accept,
751 : /// contentType, jsonldContext, ngsildConformance) are TRANSFORMED by
752 : /// `forward` rather than passed through raw, which would corrupt
753 : /// negotiation instead.
754 : /// Headers that carry the triggering caller's own credential: never copied
755 : /// from the request by a `urn:ngsi-ld:request` contextSourceInfo entry.
756 : const REQUEST_CREDENTIALS: &[&str] = &["authorization", "proxy-authorization", "cookie"];
757 :
758 : const CSI_SKIP: &[&str] = &[
759 : "ngsild-tenant",
760 : "content-length",
761 : "content-type",
762 : "host",
763 : "via",
764 : "link",
765 : "connection",
766 : "accept",
767 : "contenttype",
768 : "jsonldcontext",
769 : "ngsildconformance",
770 : ];
771 :
772 : /// Bounds wall on the forwarded-read path: a peer response larger than
773 : /// ANTARES_MAX_FED_RESPONSE_BYTES is never held in memory — the part fails
774 : /// exactly like an unparseable payload (Table 6.3.17-1, warning 111 via the
775 : /// 2xx-with-null-body arm of `read_warning`).
776 1130 : async fn read_body_capped(resp: reqwest::Response) -> Value {
777 1130 : let cap = *crate::bounds::MAX_FED_RESPONSE_BYTES;
778 1130 : if resp.content_length().is_some_and(|l| l > cap as u64) {
779 6 : tracing::warn!("federation response over the {cap}-byte cap (declared length), skipped");
780 6 : return Value::Null;
781 1124 : }
782 : #[cfg(not(target_arch = "wasm32"))]
783 1122 : let bytes = {
784 1124 : let mut resp = resp;
785 1124 : let mut buf: Vec<u8> = Vec::new();
786 : loop {
787 2016 : match resp.chunk().await {
788 894 : Ok(Some(c)) => {
789 894 : if buf.len().saturating_add(c.len()) > cap {
790 2 : tracing::warn!("federation response over the {cap}-byte cap, skipped");
791 2 : return Value::Null;
792 892 : }
793 892 : buf.extend_from_slice(&c);
794 : }
795 1122 : Ok(None) => break,
796 0 : Err(_) => return Value::Null,
797 : }
798 : }
799 1122 : buf
800 : };
801 : // the browser fetch API hands the body over whole — cap after the read
802 : #[cfg(target_arch = "wasm32")]
803 : let bytes = match resp.bytes().await {
804 : Ok(b) if b.len() <= cap => b.to_vec(),
805 : _ => return Value::Null,
806 : };
807 1122 : serde_json::from_slice(&bytes).unwrap_or(Value::Null)
808 1130 : }
809 :
810 : /// 5.7.2.4: "If split entities flag is explicitly set to true or, if not
811 : /// explicitly set, the default setting of the deployment allows split
812 : /// entities" — this deployment's default is OFF, so only the explicit flag
813 : /// engages the split branch.
814 3353 : pub(crate) fn split_entities(params: &HashMap<String, String>) -> bool {
815 3353 : params.get("splitEntities").map(String::as_str) == Some("true")
816 3353 : }
817 :
818 : /// 4.3.6.6: a registration carrying a jsonldContext contextSourceInfo key —
819 : /// forwards to it are recompacted term-by-term (attrs/type/geoproperty only).
820 358 : fn has_reg_context(reg: &FedReg) -> bool {
821 358 : reg.csi
822 358 : .iter()
823 358 : .any(|(k, _)| k.eq_ignore_ascii_case("jsonldContext"))
824 358 : }
825 :
826 : /// 4.3.6.1 fan-out: forwards to matching registrations run concurrently —
827 : /// the clause fixes the merge order (4.5.5 non-aux before aux), never the
828 : /// request order, and cross-source result ordering does not exist (the
829 : /// `ordering` parameter is a 400 outside local scope, 5.7.2.4). Results
830 : /// return in registration order so warning and merge processing stay
831 : /// deterministic. Concurrency per request is bounded by
832 : /// bounds::MAX_FED_FANOUT.
833 6463 : async fn fan_out<I, T, F, Fut>(items: Vec<I>, per_item: F) -> Vec<T>
834 6463 : where
835 6463 : F: FnMut(I) -> Fut,
836 6463 : Fut: std::future::Future<Output = T>,
837 6463 : {
838 : use futures_util::StreamExt;
839 6463 : futures_util::stream::iter(items.into_iter().map(per_item))
840 6463 : .buffered(*crate::bounds::MAX_FED_FANOUT)
841 6463 : .collect()
842 6463 : .await
843 6463 : }
844 :
845 : /// 6.6 Entity Attribute and 6.7 Entity Attribute Instance name one Attribute
846 : /// in the PATH rather than in the payload. The range is the encoded segment
847 : /// inside `url`, so a translated name can be written back over it; the name
848 : /// comes back percent-decoded, which is the form an `@context` expands. The
849 : /// segment stops at the next `/`, so `/attrs/{name}/value` and
850 : /// `/attrs/{name}/{instanceId}` both name `{name}` and nothing more.
851 64 : fn path_attr_segment(url: &str) -> Option<(std::ops::Range<usize>, String)> {
852 64 : let path = url.split_once('?').map_or(url, |(path, _)| path);
853 64 : let (before, rest) = path.split_once("/attrs/")?;
854 42 : let name = rest.split_once('/').map_or(rest, |(name, _)| name);
855 : // `before` and "/attrs/" are a prefix of `url`, so the range lies inside it
856 42 : let at = before.len().checked_add("/attrs/".len())?;
857 42 : let end = at.checked_add(name.len())?;
858 42 : (!name.is_empty()).then(|| (at..end, crate::negotiate::percent_decode(name.as_bytes())))
859 64 : }
860 :
861 : /// Table 5.2.9-2 and 5.2.34: one forward's outcome, booked on the
862 : /// registration it was made for. `ok` is the table's own failure
863 : /// definition — "an HTTP response code other than 2xx".
864 : ///
865 : /// Only an operation that reached the wire is booked. The three ways out of
866 : /// `forward` above this point — a destination the egress policy refuses, an
867 : /// open breaker, the declared 5.2.34 cooldown — are this broker declining to
868 : /// perform the operation, and the table counts operations that were
869 : /// performed: `lastFailure` is when a failure "was returned". Nothing is
870 : /// lost by that: the breaker opens only after failures that WERE attempted
871 : /// and booked, so a registration whose Context Source is down already reads
872 : /// `"failed"` before the first forward is suppressed.
873 : ///
874 : /// The stamp never fails the forward. The answer is decided by the time this
875 : /// runs, and a store that cannot take a counter must not turn a served
876 : /// response into an error. The registration mirror is not refreshed either:
877 : /// it exists to decide which registrations match, and no member here does.
878 1386 : async fn note_forward(st: &AppState, tenant: &TenantId, reg: &FedReg, ok: bool) {
879 1386 : if reg.cooldown_ms.is_some() {
880 2 : st.reg_cooldown_stamp(tenant, ®.reg_id, ok);
881 1384 : }
882 1386 : if let Err(e) = st
883 1386 : .store
884 1386 : .record_forward(tenant, ®.reg_id, &crate::state::now_iso(), ok)
885 1386 : .await
886 : {
887 0 : tracing::warn!(
888 : "forward bookkeeping for registration {} failed: {e}",
889 : reg.reg_id
890 : );
891 1386 : }
892 1386 : }
893 :
894 : /// One forward's answer: the status, the body (`Null` when there is none
895 : /// or it does not parse), and the `NGSILD-Warning` values the source sent.
896 : pub type ForwardAnswer = (u16, Value, Vec<String>);
897 :
898 : /// The answer a [`ForwardTransport`] is working on.
899 : pub type ForwardFuture<'a> =
900 : std::pin::Pin<Box<dyn std::future::Future<Output = ForwardAnswer> + Send + 'a>>;
901 :
902 : /// A transport that serves some forwards without a socket (the forward
903 : /// transport seam). Every distributed operation reaches
904 : /// it through `federation::forward`, after the request is built and before the
905 : /// egress check; vanilla registers none
906 : /// ([`AppState::with_forward_transport`]).
907 : pub trait ForwardTransport: Send + Sync {
908 : /// `Some(answer)` when this transport serves `req`; `None` sends it over
909 : /// HTTP as before. The 5.2.34 cooldown and timeout, the bookkeeping of
910 : /// Table 5.2.9-2 and the cap on the source's warnings apply to the
911 : /// answer as they do over HTTP; the egress guard and the breaker do
912 : /// not, since no socket is opened.
913 : fn forward<'a>(&'a self, req: BuiltForward<'a>) -> Option<ForwardFuture<'a>>;
914 :
915 : /// Whether Tenant `source` may store a registration whose forwards go
916 : /// to `endpoint` in Tenant `target` (`None`: the default Tenant), asked
917 : /// at 12.2.2 and 12.2.4 before anything is stored.
918 : /// `Ok` for an endpoint this transport does not serve: its forwards go
919 : /// over HTTP, as before. `Err` carries the reason of the 403.
920 26 : fn admit<'a>(
921 26 : &'a self,
922 26 : _source: &'a TenantId,
923 26 : _endpoint: &'a str,
924 26 : _target: Option<&'a str>,
925 26 : ) -> AdmitFuture<'a> {
926 26 : Box::pin(std::future::ready(Ok(())))
927 26 : }
928 :
929 : /// Whether this transport delivers the notifications sent to `uri`.
930 : /// Such a delivery opens no socket, so the egress
931 : /// guard and the breaker do not judge it; `false` leaves it to the
932 : /// binding of the URI's scheme, as before.
933 8 : fn notifies(&self, _uri: &str) -> bool {
934 8 : false
935 8 : }
936 :
937 : /// One attempt to deliver `notification`, which a Subscription of
938 : /// Tenant `tenant` sends to `uri`, an endpoint [`Self::notifies`] took.
939 : /// `Err` carries why it failed: a failed attempt under the 5.8.6 retry
940 : /// and dead-letter policy, as an HTTP one is. No subject is carried:
941 : /// delivery is broker-initiated (ADR-0020).
942 2 : fn notify<'a>(
943 2 : &'a self,
944 2 : _tenant: &'a TenantId,
945 2 : _uri: &'a str,
946 2 : _notification: &'a Value,
947 2 : ) -> NotifyFuture<'a> {
948 2 : Box::pin(std::future::ready(Err(
949 2 : "this transport delivers no notification".into(),
950 2 : )))
951 2 : }
952 : }
953 :
954 : /// What [`ForwardTransport::notify`] answers.
955 : pub type NotifyFuture<'a> =
956 : std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send + 'a>>;
957 :
958 : /// What [`ForwardTransport::admit`] answers.
959 : pub type AdmitFuture<'a> =
960 : std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send + 'a>>;
961 :
962 : /// The registered transport's [`ForwardTransport::admit`] for the
963 : /// registration document `doc` of `tenant`; `Ok` without a transport.
964 4300 : pub(crate) async fn admit_registration(
965 4300 : st: &AppState,
966 4300 : tenant: &TenantId,
967 4300 : doc: &serde_json::Map<String, Value>,
968 4300 : ) -> Result<(), crate::policy::Denied> {
969 26 : let (Some(transport), Some(endpoint)) = (
970 4300 : st.forward_transport.get(),
971 4300 : doc.get("endpoint").and_then(Value::as_str),
972 : ) else {
973 4274 : return Ok(());
974 : };
975 26 : let target = doc.get("tenant").and_then(Value::as_str);
976 26 : transport
977 26 : .admit(tenant, endpoint, target)
978 26 : .await
979 26 : .map_err(crate::policy::Denied)
980 4300 : }
981 :
982 : /// The request `federation::forward` built, as the HTTP client would send it: a
983 : /// transport reads it and cannot change what is sent, only how.
984 : #[derive(Clone)]
985 : pub struct BuiltForward<'a> {
986 : method: reqwest::Method,
987 : url: String,
988 : query: Vec<(String, String)>,
989 : headers: Vec<(String, String)>,
990 : body: Option<Vec<u8>>,
991 : tenant: &'a TenantId,
992 : reg: &'a FedReg,
993 : subject: HeaderMap,
994 : /// The registered @context the forward was switched to (4.3.6.6).
995 : answered_in: Option<std::sync::Arc<Context>>,
996 : }
997 :
998 : impl BuiltForward<'_> {
999 : /// The method.
1000 : #[must_use]
1001 20 : pub fn method(&self) -> &reqwest::Method {
1002 20 : &self.method
1003 20 : }
1004 :
1005 : /// The URL on the registration's endpoint, without the query.
1006 : #[must_use]
1007 0 : pub fn url(&self) -> &str {
1008 0 : &self.url
1009 0 : }
1010 :
1011 : /// The URL with the query encoded as the HTTP client encodes it, or
1012 : /// `None` when the URL does not parse (the HTTP client refuses it too).
1013 : #[must_use]
1014 20 : pub fn target(&self) -> Option<String> {
1015 20 : let mut url = reqwest::Url::parse(&self.url).ok()?;
1016 20 : if !self.query.is_empty() {
1017 10 : url.query_pairs_mut().extend_pairs(&self.query);
1018 10 : }
1019 20 : Some(url.into())
1020 20 : }
1021 :
1022 : /// The query parameters, in order.
1023 : #[must_use]
1024 0 : pub fn query(&self) -> &[(String, String)] {
1025 0 : &self.query
1026 0 : }
1027 :
1028 : /// The headers, in the order they are sent: `Accept`, `Via`, `Link`,
1029 : /// `NGSILD-Tenant`, the 4.3.6.5 `contextSourceInfo` ones, `Content-Type`.
1030 : #[must_use]
1031 20 : pub fn headers(&self) -> &[(String, String)] {
1032 20 : &self.headers
1033 20 : }
1034 :
1035 : /// The payload, serialized.
1036 : #[must_use]
1037 22 : pub fn body(&self) -> Option<&[u8]> {
1038 22 : self.body.as_deref()
1039 22 : }
1040 :
1041 : /// The forwarding Tenant: the one the triggering request ran in.
1042 : #[must_use]
1043 0 : pub fn tenant(&self) -> &TenantId {
1044 0 : self.tenant
1045 0 : }
1046 :
1047 : /// The registration this forward is made for.
1048 : #[must_use]
1049 0 : pub fn registration_id(&self) -> &str {
1050 0 : &self.reg.reg_id
1051 0 : }
1052 :
1053 : /// Whether the registration is the `exclusive` or `redirect` kind
1054 : /// (9.3.3), whose single failure is the operation's answer (6.3.17).
1055 : #[must_use]
1056 0 : pub fn proxied(&self) -> bool {
1057 0 : self.reg.is_proxy()
1058 0 : }
1059 :
1060 : /// The registration's 5.2.9 `tenant`: the Tenant the forward targets,
1061 : /// `None` for the default Tenant (4.14).
1062 : #[must_use]
1063 0 : pub fn registration_tenant(&self) -> Option<&str> {
1064 0 : self.reg.tenant.as_deref()
1065 0 : }
1066 :
1067 : /// The policy subject headers of the triggering request (ADR-0020):
1068 : /// never on the wire, for a transport that asks the target's engine
1069 : /// about the caller in process.
1070 : #[must_use]
1071 0 : pub fn subject(&self) -> &HeaderMap {
1072 0 : &self.subject
1073 0 : }
1074 : }
1075 :
1076 : /// 5.2.34 cooldown (per REGISTRATION, distinct from the host:port breaker):
1077 : /// inside the declared window "a timeout error response for the
1078 : /// registration is automatically returned" — the source is not contacted.
1079 1396 : fn in_cooldown(st: &AppState, tenant: &TenantId, reg: &FedReg) -> bool {
1080 1396 : reg.cooldown_ms.is_some_and(|cd| {
1081 4 : st.egress
1082 4 : .reg_in_cooldown(&crate::egress::reg_key(tenant.as_str(), ®.reg_id), cd)
1083 4 : })
1084 1396 : }
1085 :
1086 : /// 5.2.34 timeout: the per-registration budget, below the 8 s ceiling.
1087 1394 : fn deadline_ms(reg: &FedReg) -> u32 {
1088 1394 : let ceiling = crate::state::slow_factor().saturating_mul(8_000);
1089 1394 : reg.timeout_ms.map_or(ceiling, |t| t.min(ceiling)) as u32
1090 1394 : }
1091 :
1092 : /// `fut`, or `None` once `ms` have passed.
1093 : #[cfg(not(target_arch = "wasm32"))]
1094 6 : async fn within<T>(ms: u32, fut: impl std::future::Future<Output = T>) -> Option<T> {
1095 6 : tokio::time::timeout(std::time::Duration::from_millis(u64::from(ms)), fut)
1096 6 : .await
1097 6 : .ok()
1098 6 : }
1099 :
1100 : /// The browser timer is not Send, so the wait is one Send unit, as an
1101 : /// HTTP interaction is.
1102 : #[cfg(target_arch = "wasm32")]
1103 : async fn within<T>(ms: u32, fut: impl std::future::Future<Output = T>) -> Option<T> {
1104 : antares_jsonld::http_interaction(antares_jsonld::io_deadline(fut, ms)).await
1105 : }
1106 :
1107 : /// One forwarded request. `body` is compacted JSON (no @context member).
1108 : /// The registered [`ForwardTransport`] is asked first; without one, or
1109 : /// when it does not serve this forward, it goes over HTTP.
1110 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
1111 838 : pub async fn forward(
1112 838 : st: &AppState,
1113 838 : method: reqwest::Method,
1114 838 : url: String,
1115 838 : query: &[(String, String)],
1116 838 : headers: &HeaderMap,
1117 838 : tenant: &TenantId,
1118 838 : reg: &FedReg,
1119 838 : ctx_url: &str,
1120 838 : body: Option<Value>,
1121 838 : ) -> ForwardAnswer {
1122 838 : forward_in(st, method, url, query, headers, tenant, reg, ctx_url, body)
1123 838 : .await
1124 : .0
1125 830 : }
1126 :
1127 : /// [`forward`], and the @context its answer is in when that is not the
1128 : /// request's own: a forward 4.3.6.6 switched to the registered
1129 : /// `jsonldContext` is answered in that context's terms, so a read expands
1130 : /// the answer with it (`None`: with the request's).
1131 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
1132 1396 : pub(crate) async fn forward_in(
1133 1396 : st: &AppState,
1134 1396 : method: reqwest::Method,
1135 1396 : url: String,
1136 1396 : query: &[(String, String)],
1137 1396 : headers: &HeaderMap,
1138 1396 : tenant: &TenantId,
1139 1396 : reg: &FedReg,
1140 1396 : ctx_url: &str,
1141 1396 : body: Option<Value>,
1142 1396 : ) -> (ForwardAnswer, Option<std::sync::Arc<Context>>) {
1143 : // Process-wide slot first (bounds::MAX_FED_INFLIGHT): the buffers and
1144 : // connections below exist only while a slot is held.
1145 1396 : let _slot = crate::bounds::FED_INFLIGHT.acquire().await;
1146 1396 : let built = build_forward(st, method, url, query, headers, tenant, reg, ctx_url, body).await;
1147 1396 : let answered_in = built.answered_in.clone();
1148 1396 : if let Some(answer) = ask_transport(st, &built).await {
1149 8 : return (answer, answered_in);
1150 1388 : }
1151 1388 : (send_http(st, built).await, answered_in)
1152 1388 : }
1153 :
1154 : /// The forward through the registered transport, or `None` when there is
1155 : /// none or it does not serve this one.
1156 1396 : async fn ask_transport(st: &AppState, built: &BuiltForward<'_>) -> Option<ForwardAnswer> {
1157 1396 : let answer = st.forward_transport.get()?.forward(built.clone())?;
1158 8 : let (tenant, reg) = (built.tenant, built.reg);
1159 8 : if in_cooldown(st, tenant, reg) {
1160 2 : return Some((504, Value::Null, Vec::new()));
1161 6 : }
1162 6 : let Some((status, body, mut warnings)) = within(deadline_ms(reg), answer).await else {
1163 2 : note_forward(st, tenant, reg, false).await;
1164 2 : return Some((504, Value::Null, Vec::new()));
1165 : };
1166 4 : warnings.truncate(crate::bounds::MAX_PEER_WARNINGS);
1167 4 : note_forward(st, tenant, reg, (200..300).contains(&status)).await;
1168 4 : Some((status, body, warnings))
1169 1396 : }
1170 :
1171 : /// The request one forward sends: the 4.3.6.6 processing, the headers and
1172 : /// the payload, exactly as the HTTP client puts them on the wire.
1173 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
1174 1396 : async fn build_forward<'a>(
1175 1396 : st: &AppState,
1176 1396 : method: reqwest::Method,
1177 1396 : mut url: String,
1178 1396 : query: &[(String, String)],
1179 1396 : headers: &HeaderMap,
1180 1396 : tenant: &'a TenantId,
1181 1396 : reg: &'a FedReg,
1182 1396 : ctx_url: &str,
1183 1396 : mut body: Option<Value>,
1184 1396 : ) -> BuiltForward<'a> {
1185 : // 4.3.6.6: the four contextSourceInfo keys with processing
1186 : // semantics. Values were validated at registration time (5.9.2).
1187 4800 : let csi_get = |key: &str| {
1188 4800 : reg.csi
1189 4800 : .iter()
1190 4800 : .find(|(k, _)| k.eq_ignore_ascii_case(key))
1191 4800 : .map(|(_, v)| v.as_str())
1192 4800 : };
1193 : // "accept": the response shall come back in this format (the read path
1194 : // strips any body @context before expanding, so both forms import).
1195 1396 : let accept = csi_get("accept").unwrap_or("application/json");
1196 : // "ngsildConformance": amend the payload to the pinned version (4.3.6.8).
1197 1396 : if let Some(ver) = csi_get("ngsildConformance").and_then(crate::conformance::parse_version) {
1198 0 : if let Some(b) = body.as_mut() {
1199 0 : crate::conformance::amend_payload(b, ver);
1200 0 : }
1201 1396 : }
1202 : // "jsonldContext": recompact payload and term-bearing query parameters
1203 : // with the registered context, forward THAT context, Content-Type
1204 : // application/json, no @context member in the payload. Entity-shaped
1205 : // bodies only — a POST-query body is not translated and so keeps the
1206 : // original context; if either context fails to load the forward
1207 : // degrades to the original context rather than sending terms compacted
1208 : // against the wrong one.
1209 1396 : let mut link_ctx: String = ctx_url.to_owned();
1210 1396 : let mut answered_in = None;
1211 1396 : let mut query: Vec<(String, String)> = query.to_vec();
1212 : // 4.3.6.4: "a binding-specific mechanism to request operations only on
1213 : // the registered endpoint itself" — a localOnly registration (5.2.9)
1214 : // must not cascade, so the forward carries the 6.3.18 local parameter.
1215 1396 : if reg.local_only && !query.iter().any(|(k, _)| k == "local") {
1216 8 : query.push(("local".into(), "true".into()));
1217 1388 : }
1218 1396 : if let Some(reg_ctx_url) = csi_get("jsonldContext") {
1219 : // 5.5.10: both URLs are Tenant data — the caller's own @context and one
1220 : // the Registration names — so they resolve within the forwarding
1221 : // Tenant, never against a Hosted @context another Tenant stored.
1222 24 : let orig = st
1223 24 : .loader
1224 24 : .resolve_quiet_for(tenant, &Value::String(ctx_url.to_owned()))
1225 24 : .await;
1226 24 : let target = st
1227 24 : .loader
1228 24 : .resolve_quiet_for(tenant, &Value::String(reg_ctx_url.to_owned()))
1229 24 : .await;
1230 24 : if let (Ok(orig), Ok(target)) = (orig, target) {
1231 : // The payload reaching here has already been validated by the
1232 : // operation's own handler; this expansion only re-reads it to
1233 : // translate its terms, so it must accept everything that handler
1234 : // accepted and drop nothing. A merge fragment carries NGSI-LD
1235 : // Nulls (5.5.12), a temporal payload repeats a datasetId across
1236 : // instances (4.5.6), and a 4.5.7 tombstone is a deletedAt an
1237 : // expansion without `sys` discards — re-validating or thinning
1238 : // the body here changes the write the client asked for.
1239 24 : let mut translated: Option<Value> = None;
1240 24 : let mut can_switch = true;
1241 : // 6.6/6.7 name their Attribute in the PATH, which the Context
1242 : // Source expands with the @context the forward advertises — so
1243 : // the segment is translated with the payload or switching the
1244 : // context renames the target Attribute. 4.3.6.6 compacts
1245 : // "payload and query parameters" and the path is neither, but it
1246 : // carries a term the same compaction has to reach, or the rule
1247 : // cannot be applied to these resources at all. A name that does
1248 : // not expand to an absolute IRI is no term to translate, and the
1249 : // whole request stays in the @context its names are already in.
1250 24 : let path_swap = match path_attr_segment(&url) {
1251 10 : Some((range, name)) => match antares_jsonld::expand_attr_name(&name, &orig) {
1252 : // The compacted term is written into the request PATH, so
1253 : // it is held to the rule the client's own name was held to
1254 : // at the door (`antares_model::check_attr_name`): a `.` or
1255 : // `..` segment names a different resource of the Context
1256 : // Source endpoint, and a URL parser resolves it before the
1257 : // request is sent. A registered @context is client-supplied
1258 : // and may bind any term, so a term that is a dot segment
1259 : // means the request cannot be expressed in that context.
1260 10 : Ok(iri) => {
1261 10 : let term = target.compact_iri(&iri);
1262 10 : if antares_model::has_dot_segment(&term) {
1263 2 : can_switch = false;
1264 2 : None
1265 : } else {
1266 8 : Some((range, path_segment(&term)))
1267 : }
1268 : }
1269 : Err(_) => {
1270 0 : can_switch = false;
1271 0 : None
1272 : }
1273 : },
1274 14 : None => None,
1275 : };
1276 24 : match body.as_ref() {
1277 : // nothing to translate
1278 8 : None => {}
1279 : // 6.6/6.7 take an Attribute Fragment (5.6.4, 5.6.14, 5.6.19),
1280 : // not an Entity — its `type` is an Attribute type and its
1281 : // `value` a value, so reading it as an Entity would turn the
1282 : // value into a sub-Attribute. `expand_attr_fragment` is the
1283 : // reader the partial-update handler itself uses, and
1284 : // `compact_instance` its inverse.
1285 16 : Some(v) if path_swap.is_some() => match v.as_object() {
1286 6 : Some(o) => match antares_jsonld::expand_attr_fragment(o, &orig) {
1287 6 : Ok(exp) => {
1288 6 : let mut re = antares_jsonld::compact::compact_instance(&exp, &target);
1289 6 : if let Some(m) = re.as_object_mut() {
1290 6 : m.remove("@context");
1291 6 : }
1292 6 : translated = Some(re);
1293 : }
1294 0 : Err(_) => can_switch = false,
1295 : },
1296 0 : None => can_switch = false,
1297 : },
1298 10 : Some(v) => match v.as_object() {
1299 : // A 5.2.23 Query body names terms too (entities[].type,
1300 : // attrs, geoQ.geoproperty, q) and has no translation
1301 : // here, so it travels in the @context its terms are in.
1302 10 : Some(o) if o.get("type").and_then(Value::as_str) == Some("Query") => {
1303 2 : can_switch = false;
1304 2 : }
1305 8 : Some(o) => {
1306 8 : let opts = antares_jsonld::ExpandOpts {
1307 8 : fragment: o.get("id").is_none(),
1308 8 : allow_null: true,
1309 8 : merge: true,
1310 8 : temporal: true,
1311 8 : sys: true,
1312 8 : };
1313 8 : match antares_jsonld::expand_entity(o, &orig, opts) {
1314 8 : Ok(exp) => {
1315 8 : let mut re = antares_jsonld::compact::compact_entity(&exp, &target);
1316 8 : if let Some(m) = re.as_object_mut() {
1317 8 : m.remove("@context");
1318 8 : }
1319 8 : translated = Some(re);
1320 : }
1321 0 : Err(_) => can_switch = false,
1322 : }
1323 : }
1324 : // any other shape (a batch array) has no entity-document
1325 : // translation here, so it must not claim to be in the
1326 : // registered @context either
1327 0 : None => can_switch = false,
1328 : },
1329 : }
1330 : // 4.3.6.6 states one rule, not two: compact the payload with the
1331 : // registered @context "and forward with this JSON-LD context". A
1332 : // payload that could not be translated must therefore travel in
1333 : // the @context its terms ARE in — advertising the registered one
1334 : // over untranslated terms makes the Context Source expand them to
1335 : // different Fully Qualified Names (5.5.7) and write Attributes
1336 : // the client never named.
1337 24 : if can_switch {
1338 20 : if let Some((range, seg)) = path_swap {
1339 8 : url.replace_range(range, &seg);
1340 12 : }
1341 20 : if let Some(re) = translated {
1342 14 : body = Some(re);
1343 14 : }
1344 20 : for (k, v) in query.iter_mut() {
1345 10 : if matches!(k.as_str(), "attrs" | "type" | "geoproperty") {
1346 4 : *v = v
1347 4 : .split(',')
1348 4 : .map(|t| target.compact_iri(&orig.expand_key(t.trim())))
1349 4 : .collect::<Vec<_>>()
1350 4 : .join(",");
1351 6 : }
1352 : }
1353 20 : link_ctx = reg_ctx_url.to_owned();
1354 20 : answered_in = Some(target);
1355 : } else {
1356 4 : tracing::warn!(
1357 : "payload could not be expressed in the registered jsonldContext \
1358 : {reg_ctx_url}; forwarding with the original context"
1359 : );
1360 : }
1361 : } else {
1362 0 : tracing::warn!(
1363 : "registered jsonldContext {reg_ctx_url} (or the request context) \
1364 : failed to load; forwarding with the original context"
1365 : );
1366 : }
1367 1372 : }
1368 : // An inline @context (serialized JSON from ctx_link_url) cannot travel
1369 : // as a Link header — it is embedded in the body below (5.5.7/6.3.5).
1370 1396 : let inline_ctx: Option<Value> = if link_ctx.starts_with('[') || link_ctx.starts_with('{') {
1371 0 : serde_json::from_str(&link_ctx).ok()
1372 : } else {
1373 1396 : None
1374 : };
1375 1396 : let mut sent: Vec<(String, String)> = vec![
1376 1396 : ("Accept".into(), accept.to_owned()),
1377 1396 : (
1378 1396 : "Via".into(),
1379 1396 : outbound_via(headers, &alias_for(&st.host_alias, tenant)),
1380 1396 : ),
1381 : ];
1382 1396 : if inline_ctx.is_none() {
1383 1396 : sent.push((
1384 1396 : "Link".into(),
1385 1396 : format!("<{link_ctx}>; rel=\"http://www.w3.org/ns/json-ld#context\"; type=\"application/ld+json\""),
1386 1396 : ));
1387 1396 : }
1388 : // 4.14: "the Tenant information from the Context Source Registration has
1389 : // to be used. If no Tenant information is present in the Context Source
1390 : // Registration, no Tenant information is to be used and thus the default
1391 : // Tenant is targeted" — the requesting tenant never flows through; 6.3.14
1392 : // omits the header for the default Tenant.
1393 1396 : if let Some(peer_tenant) = reg.tenant.as_deref().filter(|t| *t != "default") {
1394 16 : sent.push(("NGSILD-Tenant".into(), peer_tenant.to_owned()));
1395 1380 : }
1396 : // 4.3.6.5 contextSourceInfo ⇒ extra headers; the special value
1397 : // "urn:ngsi-ld:request" copies the header from the triggering request
1398 : // (dropped when the triggering request did not carry it).
1399 1396 : for (k, v) in ®.csi {
1400 82 : let lower = k.to_ascii_lowercase();
1401 82 : if CSI_SKIP.contains(&lower.as_str()) {
1402 38 : continue;
1403 44 : }
1404 : // ADR-0020: the subject never leaves this process. A registration is
1405 : // client-supplied, so without this a registration that names the
1406 : // subject header with "urn:ngsi-ld:request" would copy the identity
1407 : // of every request that fans out to it onto the wire — the peer
1408 : // would be handed a credential the broker was only ever given to
1409 : // ask its own engine about. Dropped rather than refused at 5.9.2:
1410 : // which headers a deployment made subject headers is not something
1411 : // a client gets to probe by watching which registrations fail.
1412 44 : if crate::policy::SUBJECT_HEADERS.contains(&lower) {
1413 2 : tracing::warn!(
1414 : "registration {} asks for the policy subject header {k:?}; not forwarded",
1415 : reg.reg_id
1416 : );
1417 2 : continue;
1418 42 : }
1419 42 : let val = if v == "urn:ngsi-ld:request" {
1420 : // the triggering request's own credential is the
1421 : // caller's, not the registrant's: copying it would hand every
1422 : // caller's token or session to whoever registered the source.
1423 : // A value the registration states itself is its own and goes.
1424 18 : if REQUEST_CREDENTIALS.contains(&lower.as_str()) {
1425 6 : tracing::warn!(
1426 : "registration {} asks for the request's credential header {k:?}; not forwarded",
1427 : reg.reg_id
1428 : );
1429 6 : continue;
1430 12 : }
1431 12 : match headers.get(k.as_str()).and_then(|h| h.to_str().ok()) {
1432 6 : Some(h) => h.to_owned(),
1433 6 : None => continue,
1434 : }
1435 : } else {
1436 24 : v.clone()
1437 : };
1438 30 : sent.push((k.clone(), val));
1439 : }
1440 1396 : let mut bytes = None;
1441 1396 : if let Some(mut b) = body {
1442 : // 4.3.6.6 "contentType": provide request + @context as the MIME type
1443 : // requires — ld+json carries the context inline. When "jsonldContext"
1444 : // is also defined its own mandate wins ("the Content-Type of the
1445 : // forwarded request shall be application/json").
1446 608 : let want_ld = csi_get("contentType") == Some("application/ld+json")
1447 4 : && csi_get("jsonldContext").is_none();
1448 608 : if let Some(ic) = &inline_ctx {
1449 : // inline request @context: the only lossless carrier is the
1450 : // body itself, as application/ld+json (5.5.7/6.3.5).
1451 0 : if let Some(o) = b.as_object_mut() {
1452 0 : o.insert("@context".into(), ic.clone());
1453 0 : }
1454 0 : sent.push(("Content-Type".into(), "application/ld+json".into()));
1455 608 : } else if want_ld {
1456 4 : if let Some(o) = b.as_object_mut() {
1457 4 : o.insert("@context".into(), Value::String(link_ctx.clone()));
1458 4 : }
1459 4 : sent.push(("Content-Type".into(), "application/ld+json".into()));
1460 604 : } else {
1461 604 : sent.push(("Content-Type".into(), "application/json".into()));
1462 604 : }
1463 608 : bytes = Some(serde_json::to_vec(&b).unwrap_or_default());
1464 788 : }
1465 1396 : let mut subject = HeaderMap::new();
1466 1396 : for name in crate::policy::SUBJECT_HEADERS.iter() {
1467 8 : let Ok(name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
1468 0 : continue;
1469 : };
1470 8 : for v in headers.get_all(&name) {
1471 6 : subject.append(name.clone(), v.clone());
1472 6 : }
1473 : }
1474 1396 : BuiltForward {
1475 1396 : method,
1476 1396 : url,
1477 1396 : query,
1478 1396 : headers: sent,
1479 1396 : body: bytes,
1480 1396 : tenant,
1481 1396 : reg,
1482 1396 : subject,
1483 1396 : answered_in,
1484 1396 : }
1485 1396 : }
1486 :
1487 : /// One forward over HTTP: the egress policy, the breaker and the cooldown
1488 : /// first, then the request `built` describes.
1489 1388 : async fn send_http(st: &AppState, built: BuiltForward<'_>) -> ForwardAnswer {
1490 : let BuiltForward {
1491 1388 : method,
1492 1388 : url,
1493 1388 : query,
1494 1388 : headers: sent,
1495 1388 : body,
1496 1388 : tenant,
1497 1388 : reg,
1498 : ..
1499 1388 : } = built;
1500 : // 6.3.17: NGSILD-Warning values received from the peer are returned to
1501 : // the caller — abnormal behaviour detected downstream in a cascade
1502 : // (4.3.6.4) must surface on the aggregated response, not vanish here.
1503 : // One policy for every outbound class — scheme allowlist,
1504 : // private-range deny, per-destination circuit breaker.
1505 1388 : if let Err(e) = st.egress.check_url(&url).await {
1506 : // 5.2.9 allows any URI as the registered endpoint, and a URI's
1507 : // authority may carry credentials reqwest sends as basic auth, so a
1508 : // peer URL loses its userinfo on the way into the log.
1509 0 : tracing::warn!(
1510 : "federation forward to {} refused: {e}",
1511 0 : antares_notifier::redact_userinfo(&url)
1512 : );
1513 0 : return (502, Value::Null, Vec::new());
1514 1388 : }
1515 1388 : if st.egress.is_open(tenant.as_str(), &url) {
1516 0 : tracing::debug!(
1517 : "federation forward to {} short-circuited (breaker open)",
1518 0 : antares_notifier::redact_userinfo(&url)
1519 : );
1520 0 : return (503, Value::Null, Vec::new());
1521 1388 : }
1522 : // 5.2.34 cooldown: the source is not contacted.
1523 1388 : if in_cooldown(st, tenant, reg) {
1524 0 : return (504, Value::Null, Vec::new());
1525 1388 : }
1526 1388 : let mut req = st.fed_http.request(method, &url);
1527 4816 : for (k, v) in &sent {
1528 4816 : req = req.header(k.as_str(), v.as_str());
1529 4816 : }
1530 1388 : if !query.is_empty() {
1531 722 : req = req.query(&query);
1532 784 : }
1533 1388 : if let Some(b) = body {
1534 606 : req = req.body(b);
1535 812 : }
1536 : // The whole HTTP interaction is one Send unit (http_interaction) so
1537 : // the handler futures above stay Send on wasm32 too.
1538 1388 : antares_jsonld::http_interaction(async {
1539 : // wasm has no client-level timeout — bound the forward per request
1540 : // (mirrors the native fed_http 8 s total); a timed-out
1541 : // forward is the only failure class that feeds the breaker.
1542 : // 5.2.34 timeout bounds the forward below the 8 s ceiling.
1543 : // Natively io_deadline is a passthrough (the client owns the 8 s
1544 : // default), so the per-registration budget rides on the request.
1545 1388 : let deadline = deadline_ms(reg);
1546 : #[cfg(not(target_arch = "wasm32"))]
1547 1388 : let req = req.timeout(std::time::Duration::from_millis(deadline as u64));
1548 1388 : let sent = antares_jsonld::io_deadline(req.send(), deadline).await;
1549 1380 : let sent = match sent {
1550 1380 : Some(r) => r,
1551 : None => {
1552 0 : st.egress.record_failure(tenant.as_str(), &url);
1553 0 : note_forward(st, tenant, reg, false).await;
1554 0 : return (504, Value::Null, Vec::new());
1555 : }
1556 : };
1557 250 : match sent {
1558 1130 : Ok(resp) => {
1559 : // Any response — even 5xx — proves the peer answers within
1560 : // its own response time: no deadline cost, so no breaker
1561 : // state. Only TIMEOUT-class failures trip; a
1562 : // responding-but-erroring peer must keep being attempted,
1563 : // else unrelated registrations sharing its host:port starve.
1564 1130 : let status = resp.status().as_u16();
1565 1130 : st.egress.record_success(tenant.as_str(), &url);
1566 1130 : note_forward(st, tenant, reg, (200..300).contains(&status)).await;
1567 : // 6.3.17: the peer's own values travel on (4.3.6.4), but
1568 : // only up to the cap — the list is written by the peer and
1569 : // sent by this broker, and past the cap one source would
1570 : // both outgrow the fan-out and crowd out the warnings the
1571 : // clause obliges this broker to raise about the others.
1572 : // The head is kept: a source states its own outcome before
1573 : // the ones it relays.
1574 1130 : let all = resp.headers().get_all("NGSILD-Warning");
1575 1130 : let peer_warnings: Vec<String> = all
1576 1130 : .iter()
1577 1130 : .filter_map(|v| v.to_str().ok().map(str::to_owned))
1578 1130 : .take(crate::bounds::MAX_PEER_WARNINGS)
1579 1130 : .collect();
1580 1130 : if all.iter().count() > peer_warnings.len() {
1581 4 : tracing::warn!(
1582 : "registration {} sent more than {} NGSILD-Warning values; the rest were dropped",
1583 : reg.reg_id,
1584 : crate::bounds::MAX_PEER_WARNINGS
1585 : );
1586 1126 : }
1587 1130 : let body = read_body_capped(resp).await;
1588 1130 : (status, body, peer_warnings)
1589 : }
1590 250 : Err(e) if e.is_timeout() => {
1591 6 : st.egress.record_failure(tenant.as_str(), &url);
1592 6 : note_forward(st, tenant, reg, false).await;
1593 6 : (504, Value::Null, Vec::new())
1594 : }
1595 : Err(_) => {
1596 : // connect refused/reset: fails in milliseconds — no deadline cost;
1597 : // clearing avoids stale suppression of a restarted peer.
1598 : // 503 (not 502): NO HTTP response was received, so the read
1599 : // path classifies it under Table 6.3.17-1 code 199 ("No
1600 : // response was received from the registration endpoint"),
1601 : // never 299 ("An error response ... was received").
1602 244 : st.egress.record_success(tenant.as_str(), &url);
1603 244 : note_forward(st, tenant, reg, false).await;
1604 244 : (503, Value::Null, Vec::new())
1605 : }
1606 : }
1607 1380 : })
1608 1388 : .await
1609 1380 : }
1610 :
1611 : /// 6.3.17: a forwarded response contributes the NGSILD-Warning values the
1612 : /// peer sent AND, for an abnormal outcome, one warning of this broker's own
1613 : /// — whether or not the payload can be used. Only a 2xx payload can be:
1614 : /// 4.3.6.4 makes a failed forward a warning on a successful overall
1615 : /// response, never a failure of it, so a non-2xx registration is dropped
1616 : /// from the answer after it has been reported.
1617 556 : fn usable_payload(
1618 556 : st: &AppState,
1619 556 : tenant: &TenantId,
1620 556 : status: u16,
1621 556 : body: &Value,
1622 556 : peer_warns: Vec<String>,
1623 556 : warnings: &mut Vec<String>,
1624 556 : ) -> bool {
1625 556 : warnings.extend(peer_warns);
1626 556 : if let Some((code, text)) = read_warning(status, body) {
1627 178 : warnings.push(warning(code, &alias_for(&st.host_alias, tenant), text));
1628 378 : }
1629 556 : (200..300).contains(&status)
1630 556 : }
1631 :
1632 : /// 5.2.9 `attrs`: a registration's scope narrows what a forwarded read asks
1633 : /// for. The names go out compacted against the request @context, because the
1634 : /// peer is asked in terms, not in IRIs.
1635 208 : fn scope_attrs(reg: &FedReg, ctx: &Context) -> Option<(String, String)> {
1636 208 : let scope = reg.attrs.as_ref()?;
1637 66 : let names: Vec<String> = scope.iter().map(|a| ctx.compact_iri(a)).collect();
1638 66 : Some(("attrs".into(), names.join(",")))
1639 208 : }
1640 :
1641 : /// The 4.11 temporal window a forwarded temporal read carries (5.7.3.4 and
1642 : /// 5.7.4.4 ask the peer over the SAME window), plus sysAttrs on every
1643 : /// forwarded read: conflicting instances resolve by most recent
1644 : /// observedAt/modifiedAt (4.5.5.3), and without the remote modifiedAt the
1645 : /// winner would be arrival order.
1646 30 : fn temporal_window(params: &HashMap<String, String>) -> Vec<(String, String)> {
1647 30 : let mut query: Vec<(String, String)> = vec![("options".into(), "sysAttrs".into())];
1648 120 : for k in ["timerel", "timeAt", "endTimeAt", "timeproperty"] {
1649 120 : if let Some(v) = params.get(k) {
1650 48 : query.push((k.into(), v.clone()));
1651 72 : }
1652 : }
1653 30 : query.extend(instance_cap(params));
1654 30 : query
1655 30 : }
1656 :
1657 : /// firstN/lastN forwarded to a peer: every instance up to the end of the
1658 : /// asked page, never offsetN. The merged evolution is paged here, so a peer
1659 : /// that skipped offsetN itself would have this broker skip them twice.
1660 48 : fn instance_cap(params: &HashMap<String, String>) -> Option<(String, String)> {
1661 48 : let offset: usize = params
1662 48 : .get("offsetN")
1663 48 : .and_then(|o| o.parse().ok())
1664 48 : .unwrap_or(0);
1665 96 : ["firstN", "lastN"].into_iter().find_map(|k| {
1666 96 : let n: usize = params.get(k)?.parse().ok()?;
1667 0 : Some((k.to_owned(), n.saturating_add(offset).to_string()))
1668 96 : })
1669 48 : }
1670 :
1671 : /// 5.2.9 `attrs` again, on the way back: the scope narrows ATTRIBUTES, so the
1672 : /// entity-level members of 4.5.1 stay whatever the scope says — dropping
1673 : /// them would let 4.5.5.3 read a missing `expiresAt` as "absent from a
1674 : /// received version" and delete the local one.
1675 648 : fn narrow_to_scope(expanded: Value, reg: &FedReg) -> Option<Value> {
1676 648 : let Some(scope) = ®.attrs else {
1677 586 : return Some(expanded);
1678 : };
1679 62 : let mut out = Map::new();
1680 196 : for (k, v) in expanded.as_object()? {
1681 196 : if crate::repr::ENTITY_META.contains(&k.as_str()) || scope.iter().any(|s| s == k) {
1682 190 : out.insert(k.clone(), v.clone());
1683 190 : }
1684 : }
1685 62 : Some(Value::Object(out))
1686 648 : }
1687 :
1688 : /// Expand + registration-scope-filter one remote compacted entity.
1689 628 : pub fn import_entity(remote: &Value, reg: &FedReg, ctx: &Context) -> Option<Value> {
1690 628 : let mut obj = remote.as_object()?.clone();
1691 628 : obj.remove("@context");
1692 628 : let expanded = antares_jsonld::expand_entity(
1693 628 : &obj,
1694 628 : ctx,
1695 628 : antares_jsonld::ExpandOpts {
1696 628 : sys: true,
1697 628 : ..Default::default()
1698 628 : },
1699 : )
1700 628 : .ok()?;
1701 628 : narrow_to_scope(expanded, reg)
1702 628 : }
1703 :
1704 : /// Expand + registration-scope-filter one remote temporal entity (5.7.3.4).
1705 : /// Instances of one datasetId legally repeat in a Temporal Evolution, so
1706 : /// expansion runs in temporal mode.
1707 20 : fn import_temporal(remote: &Value, reg: &FedReg, ctx: &Context) -> Option<Value> {
1708 20 : let mut obj = remote.as_object()?.clone();
1709 20 : obj.remove("@context");
1710 20 : let expanded = antares_jsonld::expand_entity(
1711 20 : &obj,
1712 20 : ctx,
1713 20 : antares_jsonld::ExpandOpts {
1714 20 : sys: true,
1715 20 : temporal: true,
1716 20 : // 4.5.7: deletion instances (value urn:ngsi-ld:null +
1717 20 : // deletedAt) are part of a Temporal Evolution — a remote
1718 20 : // tombstone must import, not be dropped as an invalid payload.
1719 20 : allow_null: true,
1720 20 : ..Default::default()
1721 20 : },
1722 : )
1723 20 : .ok()?;
1724 20 : narrow_to_scope(expanded, reg)
1725 20 : }
1726 :
1727 : /// 5.7.3.4: forward Retrieve Temporal Evolution to matching registrations
1728 : /// that support the retrieveTemporal operation; registrations without it
1729 : /// are not contacted. Returns (auxiliary, expanded doc) pairs for the
1730 : /// caller's 4.5.5 merge.
1731 : /// 4.20: does a raw registration document support `op`? Same group tables
1732 : /// as FedReg::supports; default when the operations member is absent is
1733 : /// federationOps (5.2.9).
1734 144 : pub(crate) fn doc_supports(reg: &Value, op: &str) -> bool {
1735 144 : fed_reg_of(reg.get("id").and_then(Value::as_str).unwrap_or(""), reg).supports(op)
1736 144 : }
1737 :
1738 : /// 5.2.34 `management.localOnly`; the top-level spelling is kept for
1739 : /// compatibility (4.3.6.4 wording / older payloads). Read here so every
1740 : /// FedReg of one document agrees on it — a view that reads it and a view
1741 : /// that does not send the same peer two different questions.
1742 2068 : fn local_only_of(reg: &Value) -> bool {
1743 2068 : reg.get("localOnly")
1744 2068 : .and_then(Value::as_bool)
1745 2068 : .or_else(|| {
1746 2052 : reg.get("management")
1747 2052 : .and_then(|m| m.get("localOnly"))
1748 2052 : .and_then(Value::as_bool)
1749 2052 : })
1750 2068 : .unwrap_or(false)
1751 2068 : }
1752 :
1753 : /// A minimal FedReg view of a raw registration document — enough for
1754 : /// `forward` (endpoint/tenant/csi/alias) and `supports`.
1755 : /// 4.3.6.6 contextSourceInfo, as the key/value pairs a forward applies.
1756 2068 : fn csi_of(reg: &Value) -> Vec<(String, String)> {
1757 2068 : crate::notify::kv_pairs(reg.get("contextSourceInfo"))
1758 2068 : }
1759 :
1760 : /// A registration's endpoint without a trailing `/` or the API root it may name
1761 : /// (`…/ngsi-ld/v1`), so a forward URL never doubles the prefix (IOP fixtures).
1762 2184 : pub(crate) fn endpoint_base(endpoint: &str) -> String {
1763 2184 : let e = endpoint.trim_end_matches('/');
1764 2184 : e.strip_suffix("/ngsi-ld/v1").unwrap_or(e).to_owned()
1765 2184 : }
1766 :
1767 304 : pub(crate) fn fed_reg_of(reg_id: &str, reg: &Value) -> FedReg {
1768 304 : let endpoint = reg
1769 304 : .get("endpoint")
1770 304 : .and_then(Value::as_str)
1771 304 : .map(endpoint_base)
1772 304 : .unwrap_or_default();
1773 : FedReg {
1774 304 : reg_id: reg_id.to_owned(),
1775 304 : endpoint,
1776 304 : mode: reg
1777 304 : .get("mode")
1778 304 : .and_then(Value::as_str)
1779 304 : .unwrap_or("inclusive")
1780 304 : .to_owned(),
1781 304 : ops: reg
1782 304 : .get("operations")
1783 304 : .and_then(Value::as_array)
1784 304 : .map(|a| {
1785 300 : a.iter()
1786 300 : .filter_map(Value::as_str)
1787 300 : .map(str::to_owned)
1788 300 : .collect()
1789 300 : })
1790 304 : .unwrap_or_else(|| vec![DEFAULT_OPERATION_GROUP.into()]),
1791 304 : attrs: None,
1792 304 : ent_ids: Vec::new(),
1793 304 : ent_types: Vec::new(),
1794 304 : ent_patterns: Vec::new(),
1795 : // minimal view: no EntityInfo scope loaded ⇒ never narrow by it
1796 : ent_unrestricted: true,
1797 304 : tenant: reg.get("tenant").and_then(Value::as_str).map(str::to_owned),
1798 304 : alias: reg
1799 304 : .get("contextSourceAlias")
1800 304 : .and_then(Value::as_str)
1801 304 : .map(str::to_owned),
1802 : // 4.3.6.6: the registered headers (auth among them) travel with every
1803 : // forward, including the subscription operations that build their
1804 : // registration view from this function.
1805 304 : csi: csi_of(reg),
1806 304 : local_only: local_only_of(reg),
1807 304 : timeout_ms: reg
1808 304 : .get("management")
1809 304 : .and_then(|m| m.get("timeout"))
1810 304 : .and_then(Value::as_u64),
1811 304 : cooldown_ms: reg
1812 304 : .get("management")
1813 304 : .and_then(|m| m.get("cooldown"))
1814 304 : .and_then(Value::as_u64),
1815 : }
1816 304 : }
1817 :
1818 : /// 5.7.1.4 / 5.7.3.4: with an EntityMap in use, "only the retrieved Entity
1819 : /// Map shall be used to determine which Context Source Registrations match
1820 : /// the Entity ID" — a registration not listed in the entry does not match;
1821 : /// "the location of the linked EntityMap shall be passed as part of any
1822 : /// forwarded request" (conveyed as an extra header on the forward).
1823 206 : fn map_gate(mut reg: FedReg, map: Option<&Value>, id: &str) -> Option<FedReg> {
1824 206 : let Some(m) = map else { return Some(reg) };
1825 2 : let listed = m
1826 2 : .get("entityMap")
1827 2 : .and_then(|e| e.get(id))
1828 2 : .and_then(Value::as_array)
1829 2 : .is_some_and(|a| a.iter().any(|v| v.as_str() == Some(reg.reg_id.as_str())));
1830 2 : if !listed {
1831 2 : return None;
1832 0 : }
1833 0 : if let Some(mid) = m
1834 0 : .get("linkedMaps")
1835 0 : .and_then(|l| l.get(®.reg_id))
1836 0 : .and_then(Value::as_str)
1837 0 : {
1838 0 : reg.csi.push(("NGSILD-EntityMap".into(), mid.to_owned()));
1839 0 : }
1840 0 : Some(reg)
1841 206 : }
1842 :
1843 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
1844 980 : pub async fn fed_retrieve_temporal(
1845 980 : st: &AppState,
1846 980 : tenant: &TenantId,
1847 980 : headers: &HeaderMap,
1848 980 : ctx: &Context,
1849 980 : id: &str,
1850 980 : params: &HashMap<String, String>,
1851 980 : map: Option<&Value>,
1852 980 : warnings: &mut Vec<String>,
1853 980 : proxies: &mut Vec<FedReg>,
1854 980 : ) -> Result<Vec<(bool, Value)>, NgsiError> {
1855 980 : let spec = crate::registry::CsrSpec {
1856 980 : ids: Some(vec![id.to_owned()]),
1857 980 : temporal: crate::temporalq::TemporalQ::from_params(params, false)
1858 980 : .ok()
1859 980 : .flatten(),
1860 980 : ..Default::default()
1861 980 : };
1862 980 : let ctx_url = ctx_link_url(headers, &ctx.source);
1863 980 : let ctx_url = &ctx_url;
1864 980 : let matched = matching_regs(st, tenant, &spec, ctx, headers).await?;
1865 980 : proxies.extend(matched.iter().filter(|r| r.is_proxy()).cloned());
1866 980 : let regs: Vec<FedReg> = matched
1867 980 : .into_iter()
1868 : // 5.7.3.4: a live EntityMap in use is the ONLY source of matching
1869 : // registrations; its linked map location travels with the forward.
1870 980 : .filter_map(|reg| map_gate(reg, map, id))
1871 980 : .filter(|reg| reg.supports("retrieveTemporal"))
1872 980 : .collect();
1873 980 : let fetched = fan_out(regs, move |reg| async move {
1874 : // the temporal window travels with the forward; sysAttrs for the
1875 : // 4.5.5.3 recency arbitration
1876 8 : let mut query = temporal_window(params);
1877 8 : query.extend(scope_attrs(®, ctx));
1878 8 : let ((status, body, peer_warns), actx) = forward_in(
1879 8 : st,
1880 8 : reqwest::Method::GET,
1881 8 : format!(
1882 : "{}/ngsi-ld/v1/temporal/entities/{}",
1883 : reg.endpoint,
1884 8 : path_segment(id)
1885 : ),
1886 8 : &query,
1887 8 : headers,
1888 8 : tenant,
1889 8 : ®,
1890 8 : ctx_url,
1891 8 : None,
1892 : )
1893 8 : .await;
1894 8 : (reg, status, body, peer_warns, actx)
1895 16 : })
1896 980 : .await;
1897 980 : let mut out = Vec::new();
1898 980 : for (reg, status, body, peer_warns, actx) in fetched {
1899 8 : if !usable_payload(st, tenant, status, &body, peer_warns, warnings) {
1900 0 : continue;
1901 8 : }
1902 8 : if body.get("id").and_then(Value::as_str) != Some(id) {
1903 4 : continue;
1904 4 : }
1905 4 : match import_temporal(&body, ®, actx.as_deref().unwrap_or(ctx)) {
1906 4 : Some(doc) => out.push((reg.mode == "auxiliary", doc)),
1907 0 : None => warnings.push(warning(
1908 : 111,
1909 0 : &alias_for(&st.host_alias, tenant),
1910 0 : "the payload of the response was invalid",
1911 : )),
1912 : }
1913 : }
1914 980 : Ok(out)
1915 980 : }
1916 :
1917 : /// The rank 4.5.5.3 arbitrates on: TS 104 175 8.5.3 has any instance with
1918 : /// an observedAt take precedence over every instance without one, the most
1919 : /// recent observedAt among them; modifiedAt decides only between instances
1920 : /// that observed nothing. The DateTime is a comparable key: 4.6.3 admits the
1921 : /// same instant with or without a fraction, so a raw string compare would
1922 : /// rank `…:01.500Z` below `…:01Z`.
1923 220 : fn recency(inst: &Value) -> (bool, String) {
1924 368 : let at = |k| inst.get(k).and_then(Value::as_str);
1925 220 : match at("observedAt") {
1926 72 : Some(observed) => (true, antares_model::dt_key(observed)),
1927 148 : None => (false, antares_model::dt_key(at("modifiedAt").unwrap_or(""))),
1928 : }
1929 220 : }
1930 :
1931 : /// 4.5.5.2 Processing of Conflicting Transient Entities: for each received
1932 : /// Entity version with an entity-level expiresAt, add it as a non-reified
1933 : /// expiresAt on every Attribute instance that lacks one, and cap any
1934 : /// Attribute-level expiresAt further in the future to the entity's (earlier)
1935 : /// DateTime.
1936 176 : fn push_down_expires(doc: &mut Value) {
1937 176 : let Some(o) = doc.as_object_mut() else { return };
1938 176 : let Some(exp) = o.get("expiresAt").and_then(Value::as_str).map(String::from) else {
1939 160 : return;
1940 : };
1941 52 : for (k, v) in o.iter_mut() {
1942 48 : if matches!(
1943 52 : k.as_str(),
1944 52 : "id" | "type" | "scope" | "expiresAt" | "createdAt" | "modifiedAt" | "deletedAt"
1945 : ) {
1946 48 : continue;
1947 4 : }
1948 4 : let Some(instances) = v.as_array_mut() else {
1949 0 : continue;
1950 : };
1951 12 : for inst in instances.iter_mut().filter_map(Value::as_object_mut) {
1952 12 : match inst.get("expiresAt").and_then(Value::as_str) {
1953 8 : Some(ae) if antares_model::dt_key(ae) <= antares_model::dt_key(&exp) => {}
1954 8 : _ => {
1955 8 : inst.insert("expiresAt".into(), Value::String(exp.clone()));
1956 8 : }
1957 : }
1958 : }
1959 : }
1960 176 : }
1961 :
1962 : /// 4.5.5.3 first step: "if an expiresAt DateTime is present on
1963 : /// the Attribute and the date lies in the past, it shall be discarded" —
1964 : /// BEFORE any recency comparison.
1965 232 : fn expired(inst: &Value, now: &str) -> bool {
1966 232 : inst.get("expiresAt")
1967 232 : .and_then(Value::as_str)
1968 232 : .is_some_and(|e| antares_model::dt_key(e) < antares_model::dt_key(now))
1969 232 : }
1970 :
1971 : /// 4.3.6.2: "An auxiliary Context Source Registration never overrides data
1972 : /// held directly within a Context Broker. […] Context data from auxiliary
1973 : /// context sources is only included if it is supplementary."
1974 : /// Merge attributes of `add` into `base` (auxiliary sources never override —
1975 : /// base wins; otherwise conflicting instances resolve per 4.5.5.3: discard
1976 : /// past-expiresAt instances first, then most recent observedAt/modifiedAt).
1977 176 : pub fn merge_docs(base: &mut Value, add: &Value, aux: bool) {
1978 176 : let now = crate::state::now_iso();
1979 176 : let mut add = add.clone();
1980 176 : push_down_expires(&mut add);
1981 176 : let Some(bo) = base.as_object_mut() else {
1982 0 : return;
1983 : };
1984 176 : let Some(ao) = add.as_object() else { return };
1985 : // 4.5.5.3: entity-level expiresAt — "missing from at least one version of
1986 : // the Entity received" → removed; present in all versions → the DateTime
1987 : // furthest in the future. 4.3.6.2 keeps auxiliary versions out of this:
1988 : // they never override data the broker holds directly, and removing or
1989 : // extending the lifetime is an override.
1990 176 : if !aux {
1991 : match (
1992 154 : bo.get("expiresAt").and_then(Value::as_str),
1993 154 : ao.get("expiresAt").and_then(Value::as_str),
1994 : ) {
1995 4 : (Some(b), Some(a)) => {
1996 4 : if antares_model::dt_key(a) > antares_model::dt_key(b) {
1997 4 : bo.insert("expiresAt".into(), Value::String(a.to_owned()));
1998 4 : }
1999 : }
2000 150 : _ => {
2001 150 : bo.remove("expiresAt");
2002 150 : }
2003 : }
2004 22 : }
2005 590 : for (k, v) in ao {
2006 590 : if k == "expiresAt" {
2007 16 : continue; // resolved above
2008 574 : }
2009 574 : match bo.get_mut(k) {
2010 88 : None => {
2011 88 : bo.insert(k.clone(), v.clone());
2012 88 : }
2013 486 : Some(cur) if !aux && !matches!(k.as_str(), "id" | "type" | "scope") => {
2014 118 : let (Some(ca), Some(aa)) = (cur.as_array_mut(), v.as_array()) else {
2015 0 : continue;
2016 : };
2017 118 : for ai in aa {
2018 118 : if expired(ai, &now) {
2019 4 : continue; // 4.5.5.3: discarded before comparison
2020 114 : }
2021 114 : let ds = ai.get("datasetId").and_then(Value::as_str);
2022 114 : match ca
2023 114 : .iter_mut()
2024 114 : .find(|ci| ci.get("datasetId").and_then(Value::as_str) == ds)
2025 : {
2026 0 : None => ca.push(ai.clone()),
2027 114 : Some(ci) => {
2028 114 : if expired(ci, &now) || recency(ai) > recency(ci) {
2029 24 : *ci = ai.clone();
2030 90 : }
2031 : }
2032 : }
2033 : }
2034 : }
2035 368 : _ => {}
2036 : }
2037 : }
2038 176 : }
2039 :
2040 : // ---------- distributed reads ----------
2041 :
2042 : /// Federated retrieve: internal docs from every matching registration,
2043 : /// (aux, doc) pairs so callers can order the merge. `sole_failure` receives
2044 : /// the failed answer when the one registration asked is exclusive or
2045 : /// redirect: 6.3.17 p.278 gives that single source its own error statuses.
2046 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
2047 2008 : pub async fn fed_retrieve(
2048 2008 : st: &AppState,
2049 2008 : tenant: &TenantId,
2050 2008 : headers: &HeaderMap,
2051 2008 : ctx: &Context,
2052 2008 : id: &str,
2053 2008 : map: Option<&Value>,
2054 2008 : except_reg: Option<&str>,
2055 2008 : warnings: &mut Vec<String>,
2056 2008 : sole_failure: &mut Option<Part>,
2057 2008 : ) -> Result<Vec<(bool, Value)>, NgsiError> {
2058 2008 : let spec = crate::registry::CsrSpec {
2059 2008 : ids: Some(vec![id.to_owned()]),
2060 2008 : ..Default::default()
2061 2008 : };
2062 2008 : let ctx_url = ctx_link_url(headers, &ctx.source);
2063 2008 : let ctx_url = &ctx_url;
2064 2008 : let regs: Vec<FedReg> = matching_regs(st, tenant, &spec, ctx, headers)
2065 2008 : .await?
2066 2006 : .into_iter()
2067 : // 5.8.6 splitEntities merge: "except for the one from which the
2068 : // Notification has been received"
2069 2006 : .filter(|reg| !except_reg.is_some_and(|x| x == reg.reg_id))
2070 : // 5.7.1.4: a live EntityMap in use is the ONLY source of matching
2071 : // registrations; its linked map location travels with the forward.
2072 2006 : .filter_map(|reg| map_gate(reg, map, id))
2073 2006 : .filter(|reg| reg.read_op().is_some())
2074 2006 : .collect();
2075 2006 : let fetched = fan_out(regs, move |reg| async move {
2076 178 : let Some(op) = reg.read_op() else {
2077 0 : return (reg, 0, Value::Null, Vec::new(), None);
2078 : };
2079 : // sysAttrs on every forwarded read: conflicting instances resolve by
2080 : // most recent observedAt/modifiedAt (4.5.5.3) — without the remote
2081 : // modifiedAt the winner would be arrival order, i.e. indeterminate.
2082 178 : let mut query: Vec<(String, String)> = vec![("options".into(), "sysAttrs".into())];
2083 178 : query.extend(scope_attrs(®, ctx));
2084 178 : let ((status, body, peer_warns), actx) = match op {
2085 178 : "retrieveEntity" => {
2086 170 : forward_in(
2087 170 : st,
2088 170 : reqwest::Method::GET,
2089 170 : format!("{}/ngsi-ld/v1/entities/{}", reg.endpoint, path_segment(id)),
2090 170 : &query,
2091 170 : headers,
2092 170 : tenant,
2093 170 : ®,
2094 170 : ctx_url,
2095 170 : None,
2096 170 : )
2097 170 : .await
2098 : }
2099 8 : "queryEntity" => {
2100 4 : let t = reg
2101 4 : .ent_types
2102 4 : .first()
2103 4 : .map(|t| ctx.compact_iri(t))
2104 4 : .unwrap_or_else(|| "*".into());
2105 4 : query.push(("type".into(), t));
2106 4 : query.push(("id".into(), id.to_owned()));
2107 4 : forward_in(
2108 4 : st,
2109 4 : reqwest::Method::GET,
2110 4 : format!("{}/ngsi-ld/v1/entities", reg.endpoint),
2111 4 : &query,
2112 4 : headers,
2113 4 : tenant,
2114 4 : ®,
2115 4 : ctx_url,
2116 4 : None,
2117 4 : )
2118 4 : .await
2119 : }
2120 : _ => {
2121 : // queryBatch
2122 4 : let mut sel = Map::new();
2123 4 : if let Some(t) = reg.ent_types.first() {
2124 4 : sel.insert("type".into(), Value::String(ctx.compact_iri(t)));
2125 4 : }
2126 4 : sel.insert("id".into(), Value::String(id.to_owned()));
2127 4 : forward_in(
2128 4 : st,
2129 4 : reqwest::Method::POST,
2130 4 : format!("{}/ngsi-ld/v1/entityOperations/query", reg.endpoint),
2131 4 : &query,
2132 4 : headers,
2133 4 : tenant,
2134 4 : ®,
2135 4 : ctx_url,
2136 4 : Some(json!({"type": "Query", "entities": [Value::Object(sel)]})),
2137 4 : )
2138 4 : .await
2139 : }
2140 : };
2141 178 : (reg, status, body, peer_warns, actx)
2142 356 : })
2143 2006 : .await;
2144 2006 : let mut out = Vec::new();
2145 2006 : let single = fetched.len() == 1;
2146 2006 : for (reg, status, body, peer_warns, actx) in fetched {
2147 178 : if !usable_payload(st, tenant, status, &body, peer_warns, warnings) {
2148 88 : if single && reg.is_proxy() {
2149 12 : *sole_failure = Some(Part {
2150 12 : status,
2151 12 : detail: format!(
2152 12 : "distributed operation to registration {} returned {status}",
2153 12 : reg.reg_id
2154 12 : ),
2155 12 : registration: Some(reg.reg_id.clone()),
2156 12 : proxy: true,
2157 12 : update: None,
2158 12 : conflict: false,
2159 12 : });
2160 76 : }
2161 88 : continue;
2162 90 : }
2163 90 : let candidates: Vec<&Value> = match &body {
2164 4 : Value::Array(a) => a.iter().collect(),
2165 78 : Value::Object(_) => vec![&body],
2166 8 : _ => continue,
2167 : };
2168 82 : for c in candidates {
2169 78 : if c.get("id").and_then(Value::as_str) != Some(id) {
2170 0 : continue;
2171 78 : }
2172 78 : match import_entity(c, ®, actx.as_deref().unwrap_or(ctx)) {
2173 78 : Some(doc) => out.push((reg.mode == "auxiliary", doc)),
2174 : // received in time, but not a parseable NGSI-LD entity (111)
2175 0 : None => warnings.push(warning(
2176 : 111,
2177 0 : &alias_for(&st.host_alias, tenant),
2178 0 : "the payload of the response was invalid",
2179 : )),
2180 : }
2181 : }
2182 : }
2183 2006 : Ok(out)
2184 2008 : }
2185 :
2186 : /// Federated query: internal docs matching a type query, per registration.
2187 : /// The `CsrSpec` a Query Entities request matches registrations against.
2188 4387 : fn query_spec(ctx: &Context, params: &HashMap<String, String>) -> crate::registry::CsrSpec {
2189 : // 4.17: the parameter is ONE Entity Type Selection, so it travels whole —
2190 : // splitting it on commas turned a conjunction like (A;B) into two terms
2191 : // that match nothing, and no registration was consulted for it.
2192 4387 : let types: Option<Vec<String>> = params.get("type").cloned().map(|s| vec![s]);
2193 4387 : let ids: Option<Vec<String>> = params
2194 4387 : .get("id")
2195 4387 : .map(|s| s.split(',').map(str::to_owned).collect());
2196 : crate::registry::CsrSpec {
2197 4387 : types,
2198 4387 : ids,
2199 : // 5.12: "the id pattern (if present)" is part of the query-side
2200 : // Entity specification matched against EntityInfo elements
2201 4387 : id_pattern: params.get("idPattern").cloned(),
2202 : // 5.12 attribute conditions: the "list of Attribute names (if
2203 : // present)" gates which RegistrationInfos match
2204 4387 : attrs: params
2205 4387 : .get("attrs")
2206 4387 : .map(|s| s.split(',').map(|a| ctx.expand_key(a.trim())).collect()),
2207 : // 5.12 datasetId condition (should-level): disjoint sets don't match
2208 4387 : dataset_ids: params
2209 4387 : .get("datasetId")
2210 4387 : .map(|s| s.split(',').map(|d| d.trim().to_owned()).collect()),
2211 4387 : csf: params.get("csf").and_then(|c| antares_ql::parse_q(c).ok()),
2212 4387 : geo: antares_ql::geo::GeoQuery::from_params(params)
2213 4387 : .ok()
2214 4387 : .flatten(),
2215 4387 : ..Default::default()
2216 : }
2217 4387 : }
2218 :
2219 : /// Will this query actually fan out to a Context Source?
2220 : ///
2221 : /// 5.7.2.4 forbids ordering when "the execution of the operation is not
2222 : /// limited to the local scope", and 4.23.1 says "Sort ordering is never
2223 : /// applied to distributed operations". The subject is the *execution*, not the
2224 : /// presence of `local=true`: a query no registration matches executes locally
2225 : /// whether or not the client said so. Reading it as "local=true is mandatory
2226 : /// for orderBy" would fail ETSI's own 019_19, which orders without it.
2227 950 : pub async fn would_federate(
2228 950 : st: &AppState,
2229 950 : tenant: &TenantId,
2230 950 : ctx: &Context,
2231 950 : params: &HashMap<String, String>,
2232 950 : headers: &HeaderMap,
2233 950 : ) -> Result<bool, NgsiError> {
2234 950 : if !active(params) || via_hops(headers) > MAX_VIA_HOPS {
2235 64 : return Ok(false);
2236 886 : }
2237 : // the verdict only — no forwarding set is compiled for it
2238 886 : let spec = query_spec(ctx, params);
2239 886 : let seen = via_tokens(headers);
2240 886 : Ok(reg_docs(st, tenant, &spec, ctx)
2241 886 : .await?
2242 886 : .iter()
2243 886 : .any(|doc| reg_candidate(doc, &spec, ctx, &seen).is_some()))
2244 950 : }
2245 :
2246 : /// May a fan-out response contribute the entity `id` under registration
2247 : /// `reg`, for a request whose id selection is `spec`?
2248 : ///
2249 : /// A source only speaks for what its registration covers — an id outside
2250 : /// that scope is dropped rather than merged, or a peer could overwrite
2251 : /// unrelated local attributes on recency (4.5.5.3). But 5.12's matching is
2252 : /// deliberately over-broad for patterns: condition 5 forwards when "both a
2253 : /// specified id pattern and an idPattern in the Entity Info are present
2254 : /// (since in the general case it is not easily feasible to determine if
2255 : /// there can be identifiers matching both patterns)". The same
2256 : /// undecidability applies to the ANSWER: an id the CLIENT's own selection
2257 : /// admits cannot be refused on the registration's pattern. A peer therefore
2258 : /// contributes only ids inside its registration scope or inside what the
2259 : /// client itself asked to see — never a third thing.
2260 582 : fn admits_import(reg: &FedReg, spec: &crate::registry::CsrSpec, id: &str) -> bool {
2261 582 : reg.can_match_id(id)
2262 20 : || spec
2263 20 : .ids
2264 20 : .as_ref()
2265 20 : .is_some_and(|ids| ids.iter().any(|i| i == id))
2266 16 : || spec
2267 16 : .id_pattern
2268 16 : .as_deref()
2269 16 : .is_some_and(|p| antares_ql::regex::compile(p).is_ok_and(|re| re.find(id).is_some()))
2270 582 : }
2271 :
2272 : /// The scope gate of a fan-out answer: `c` is this source's data (4.3.6.1:
2273 : /// a type outside the registration's EntityInfo is not, whatever the client
2274 : /// asked for) and [`admits_import`] admits its id.
2275 564 : fn in_scope(reg: &FedReg, spec: &crate::registry::CsrSpec, ctx: &Context, c: &Value) -> bool {
2276 564 : c.as_object().is_some_and(|o| reg.covers_types(o, ctx))
2277 558 : && c.get("id")
2278 558 : .and_then(Value::as_str)
2279 558 : .is_some_and(|i| admits_import(reg, spec, i))
2280 564 : }
2281 :
2282 : /// Whether a query parameter travels on a forward. A narrowed `q` (the
2283 : /// caller's joined with a policy's, `antares_ql::parse_narrowed_q`) can be
2284 : /// past the caps a peer counts it with, `antares_ql::parse_q`'s; the peer's
2285 : /// TooComplexQuery would then tell the caller that a policy predicate is
2286 : /// there and how large (threat V23). Such a `q` stays home: the
2287 : /// local re-check applies it to the aggregate (5.7.2.4, 5.7.4.4), as it
2288 : /// does for split entities.
2289 : // The peer is modelled with our own caps; a peer with smaller ones
2290 : // can still refuse the conjunction. Forward only the caller's own q then.
2291 72 : fn forwardable(k: &str, v: &str) -> bool {
2292 72 : k != "q" || antares_ql::parse_q(v).is_ok()
2293 72 : }
2294 :
2295 2301 : pub async fn fed_query(
2296 2301 : st: &AppState,
2297 2301 : tenant: &TenantId,
2298 2301 : headers: &HeaderMap,
2299 2301 : ctx: &Context,
2300 2301 : params: &HashMap<String, String>,
2301 2301 : warnings: &mut Vec<String>,
2302 2301 : ) -> Result<Vec<(bool, Value)>, NgsiError> {
2303 2301 : let spec = query_spec(ctx, params);
2304 2301 : let ctx_url = ctx_link_url(headers, &ctx.source);
2305 2301 : let ctx_url = &ctx_url;
2306 2301 : let regs: Vec<FedReg> = matching_regs(st, tenant, &spec, ctx, headers)
2307 2301 : .await?
2308 2297 : .into_iter()
2309 2297 : .filter(|r| r.query_op().is_some())
2310 2297 : .collect();
2311 2297 : let fetched = fan_out(regs, move |reg| async move {
2312 348 : let Some(op) = reg.query_op() else {
2313 0 : return (reg, 0, Value::Null, Vec::new(), None);
2314 : };
2315 : // The forwarded selection is decided ONCE and then rendered either as
2316 : // query parameters (Query Entities, 5.7.2) or as a Query body
2317 : // (5.2.23) — the two must ask the peer the same question.
2318 : //
2319 : // 4.3.6.1: the forwarded id list carries only ids this registration
2320 : // can match — never the full client list.
2321 348 : let ids: Option<String> = match params.get("id") {
2322 104 : Some(ids) => {
2323 104 : let keep: Vec<&str> = ids
2324 104 : .split(',')
2325 198 : .filter(|i| reg.can_match_id(i.trim()))
2326 104 : .collect();
2327 104 : (!keep.is_empty()).then(|| keep.join(","))
2328 : }
2329 : // the registration is scoped to exact ids only — ask for those
2330 244 : None if !reg.ent_unrestricted
2331 26 : && reg.ent_patterns.is_empty()
2332 18 : && !reg.ent_ids.is_empty() =>
2333 : {
2334 18 : Some(reg.ent_ids.join(","))
2335 : }
2336 226 : None => None,
2337 : };
2338 348 : let attrs: Option<String> = match ®.attrs {
2339 38 : Some(scope) => Some(
2340 38 : scope
2341 38 : .iter()
2342 38 : .map(|a| ctx.compact_iri(a))
2343 38 : .collect::<Vec<String>>()
2344 38 : .join(","),
2345 : ),
2346 310 : None => params.get("attrs").cloned(),
2347 : };
2348 : // 5.7.2.4: with split entities the filters "shall be removed
2349 : // before forwarding" and re-applied on the aggregate (which the
2350 : // local re-check always does); otherwise the request is forwarded
2351 : // WITH its filters, so the peer returns its filtered subset
2352 : // instead of everything. A registered jsonldContext (4.3.6.6)
2353 : // recompacts only attrs/type/geoproperty — q/scopeQ terms cannot
2354 : // be recompacted, so push-down is skipped there rather than
2355 : // filtering at the remote against the wrong terms.
2356 348 : let push_filters = !split_entities(params) && !has_reg_context(®);
2357 2088 : let filter = |k: &str| {
2358 2088 : push_filters
2359 2088 : .then(|| params.get(k))
2360 2088 : .flatten()
2361 2088 : .filter(|v| forwardable(k, v))
2362 2088 : .map(String::to_owned)
2363 2088 : };
2364 348 : let ((status, body, peer_warns), actx) = if op == "queryBatch" {
2365 22 : let mut sel = Map::new();
2366 22 : if let Some(t) = params.get("type") {
2367 22 : sel.insert("type".into(), Value::String(t.clone()));
2368 22 : }
2369 14 : match &ids {
2370 : // 5.2.33: `id` is one URI or an array of them, and it takes
2371 : // precedence over idPattern — so a pattern only travels when
2372 : // no id list survived the narrowing.
2373 14 : Some(list) if list.contains(',') => {
2374 0 : let arr: Vec<Value> =
2375 0 : list.split(',').map(|i| Value::String(i.into())).collect();
2376 0 : sel.insert("id".into(), Value::Array(arr));
2377 : }
2378 14 : Some(one) => {
2379 14 : sel.insert("id".into(), Value::String(one.clone()));
2380 14 : }
2381 : None => {
2382 8 : if let Some(p) = params.get("idPattern") {
2383 2 : sel.insert("idPattern".into(), Value::String(p.clone()));
2384 6 : }
2385 : }
2386 : }
2387 22 : let mut q_body = Map::new();
2388 22 : q_body.insert("type".into(), Value::String("Query".into()));
2389 22 : if !sel.is_empty() {
2390 22 : q_body.insert("entities".into(), json!([Value::Object(sel)]));
2391 22 : }
2392 22 : if let Some(a) = &attrs {
2393 8 : let list: Vec<Value> = a.split(',').map(|n| Value::String(n.into())).collect();
2394 8 : q_body.insert("attrs".into(), Value::Array(list));
2395 14 : }
2396 22 : if let Some(q) = filter("q") {
2397 2 : q_body.insert("q".into(), Value::String(q));
2398 20 : }
2399 22 : if let Some(s) = filter("scopeQ") {
2400 2 : q_body.insert("scopeQ".into(), Value::String(s));
2401 20 : }
2402 22 : let mut geo = Map::new();
2403 88 : for k in ["georel", "geometry", "coordinates", "geoproperty"] {
2404 88 : if let Some(v) = filter(k) {
2405 : // 5.2.13 GeoQuery carries coordinates as the GeoJSON
2406 : // value, not as the query-string spelling of it.
2407 6 : let parsed = if k == "coordinates" {
2408 2 : serde_json::from_str(&v).unwrap_or(Value::String(v))
2409 : } else {
2410 4 : Value::String(v)
2411 : };
2412 6 : geo.insert(k.into(), parsed);
2413 82 : }
2414 : }
2415 22 : if !geo.is_empty() {
2416 2 : q_body.insert("geoQ".into(), Value::Object(geo));
2417 20 : }
2418 22 : forward_in(
2419 22 : st,
2420 22 : reqwest::Method::POST,
2421 22 : format!("{}/ngsi-ld/v1/entityOperations/query", reg.endpoint),
2422 22 : &[("options".into(), "sysAttrs".into())],
2423 22 : headers,
2424 22 : tenant,
2425 22 : ®,
2426 22 : ctx_url,
2427 22 : Some(Value::Object(q_body)),
2428 22 : )
2429 22 : .await
2430 : } else {
2431 326 : let mut query: Vec<(String, String)> = vec![("options".into(), "sysAttrs".into())];
2432 326 : if let Some(t) = params.get("type") {
2433 326 : query.push(("type".into(), t.clone()));
2434 326 : }
2435 : // Table 6.4.3.2-1, and 5.2.33 for the body rendering below:
2436 : // `id` takes precedence over `idPattern`, so a pattern travels
2437 : // only when no id list survived the narrowing. The two
2438 : // renderings ask the peer the same question.
2439 326 : match &ids {
2440 100 : Some(list) => query.push(("id".into(), list.clone())),
2441 : None => {
2442 226 : if let Some(p) = params.get("idPattern") {
2443 22 : query.push(("idPattern".into(), p.clone()));
2444 204 : }
2445 : }
2446 : }
2447 326 : if let Some(a) = &attrs {
2448 48 : query.push(("attrs".into(), a.clone()));
2449 278 : }
2450 1956 : for k in [
2451 326 : "q",
2452 326 : "georel",
2453 326 : "geometry",
2454 326 : "coordinates",
2455 326 : "geoproperty",
2456 326 : "scopeQ",
2457 326 : ] {
2458 1956 : if let Some(v) = filter(k) {
2459 32 : query.push((k.into(), v));
2460 1924 : }
2461 : }
2462 326 : forward_in(
2463 326 : st,
2464 326 : reqwest::Method::GET,
2465 326 : format!("{}/ngsi-ld/v1/entities", reg.endpoint),
2466 326 : &query,
2467 326 : headers,
2468 326 : tenant,
2469 326 : ®,
2470 326 : ctx_url,
2471 326 : None,
2472 326 : )
2473 326 : .await
2474 : };
2475 348 : (reg, status, body, peer_warns, actx)
2476 696 : })
2477 2297 : .await;
2478 2297 : let mut out = Vec::new();
2479 2297 : for (reg, status, body, peer_warns, actx) in fetched {
2480 348 : if !usable_payload(st, tenant, status, &body, peer_warns, warnings) {
2481 70 : continue;
2482 278 : }
2483 278 : if let Value::Array(a) = &body {
2484 : // Scope gate — see in_scope.
2485 544 : for c in a
2486 260 : .iter()
2487 550 : .filter(|c| in_scope(®, &spec, actx.as_deref().unwrap_or(ctx), c))
2488 : {
2489 542 : match import_entity(c, ®, actx.as_deref().unwrap_or(ctx)) {
2490 542 : Some(doc) => out.push((reg.mode == "auxiliary", doc)),
2491 0 : None => warnings.push(warning(
2492 : 111,
2493 0 : &alias_for(&st.host_alias, tenant),
2494 0 : "the payload of the response was invalid",
2495 : )),
2496 : }
2497 : }
2498 18 : }
2499 : }
2500 2297 : Ok(out)
2501 2301 : }
2502 :
2503 : /// 5.14.4.4 / 5.14.5.4: forward EntityMap creation to matching registrations
2504 : /// that support `op` (createEntityMapQueryEntity / …QueryTemporal, 4.20);
2505 : /// with split entities in play the value/geo/scope filters are removed
2506 : /// before forwarding. Returns (registration id, returned EntityMap) pairs —
2507 : /// the caller merges them into the local map's entityMap/linkedMaps.
2508 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
2509 224 : pub(crate) async fn fed_entity_maps(
2510 224 : st: &AppState,
2511 224 : tenant: &TenantId,
2512 224 : headers: &HeaderMap,
2513 224 : ctx: &Context,
2514 224 : params: &HashMap<String, String>,
2515 224 : split: bool,
2516 224 : op: &str,
2517 224 : path: &str,
2518 224 : ) -> Result<Vec<(String, Value)>, NgsiError> {
2519 224 : let spec = query_spec(ctx, params);
2520 224 : let ctx_url = ctx_link_url(headers, &ctx.source);
2521 224 : let ctx_url = &ctx_url;
2522 224 : let regs: Vec<FedReg> = matching_regs(st, tenant, &spec, ctx, headers)
2523 224 : .await?
2524 224 : .into_iter()
2525 224 : .filter(|reg| reg.supports(op))
2526 224 : .collect();
2527 : // a narrowed q the peer's caps refuse stays home, and the map the peer
2528 : // answers without it is narrowed here
2529 224 : let held_back = params
2530 224 : .get("q")
2531 224 : .filter(|q| !split && !forwardable("q", q))
2532 224 : .map(|q| antares_ql::parse_narrowed_q(q))
2533 224 : .transpose()?;
2534 224 : let held_back = &held_back;
2535 224 : let fetched = fan_out(regs, move |reg| async move {
2536 18 : let mut query: Vec<(String, String)> = Vec::new();
2537 126 : for k in [
2538 18 : "id",
2539 18 : "idPattern",
2540 18 : "type",
2541 18 : "timerel",
2542 18 : "timeAt",
2543 18 : "endTimeAt",
2544 18 : "timeproperty",
2545 18 : ] {
2546 126 : if let Some(v) = params.get(k) {
2547 18 : query.push((k.to_owned(), v.clone()));
2548 108 : }
2549 : }
2550 18 : query.extend(instance_cap(params));
2551 18 : if !split {
2552 144 : for k in [
2553 18 : "attrs",
2554 18 : "q",
2555 18 : "georel",
2556 18 : "geometry",
2557 18 : "coordinates",
2558 18 : "geoproperty",
2559 18 : "scopeQ",
2560 18 : "lang",
2561 18 : ] {
2562 144 : if let Some(v) = params.get(k).filter(|v| forwardable(k, v)) {
2563 2 : query.push((k.to_owned(), v.clone()));
2564 142 : }
2565 : }
2566 0 : }
2567 18 : let (status, mut body, _) = forward(
2568 18 : st,
2569 18 : reqwest::Method::GET,
2570 18 : format!("{}/ngsi-ld/v1/{path}", reg.endpoint),
2571 18 : &query,
2572 18 : headers,
2573 18 : tenant,
2574 18 : ®,
2575 18 : ctx_url,
2576 18 : None,
2577 : )
2578 18 : .await;
2579 18 : if let Some(q) = held_back.as_ref().filter(|_| (200..300).contains(&status)) {
2580 2 : let peer = Peer {
2581 2 : st,
2582 2 : tenant,
2583 2 : headers,
2584 2 : ctx_url,
2585 2 : reg: ®,
2586 2 : };
2587 2 : narrow_map_ids(peer, ctx, params.get("type"), q, &mut body).await;
2588 16 : }
2589 18 : (reg, status, body)
2590 36 : })
2591 224 : .await;
2592 224 : let mut out = Vec::new();
2593 224 : for (reg, status, body) in fetched {
2594 18 : if (200..300).contains(&status) && body.get("entityMap").is_some() {
2595 14 : out.push((reg.reg_id.clone(), body));
2596 14 : }
2597 : }
2598 224 : Ok(out)
2599 224 : }
2600 :
2601 : /// What every forward to one registered peer shares.
2602 : struct Peer<'a> {
2603 : st: &'a AppState,
2604 : tenant: &'a TenantId,
2605 : headers: &'a HeaderMap,
2606 : ctx_url: &'a str,
2607 : reg: &'a FedReg,
2608 : }
2609 :
2610 : /// A peer's EntityMap asked without a narrowed `q` its caps refuse (
2611 : /// threat V23): the ids it lists are a superset of the narrowing's, and the
2612 : /// map is served to the caller, so each id stays only when the peer's own
2613 : /// Entity is selected by `q` here. The Entities are read from the same peer
2614 : /// by id, as Query Entities forwards them; a linked path does not resolve,
2615 : /// so it never holds, and a peer that cannot be read contributes no id.
2616 2 : async fn narrow_map_ids(
2617 2 : peer: Peer<'_>,
2618 2 : ctx: &Context,
2619 2 : type_param: Option<&String>,
2620 2 : q: &antares_ql::QNode,
2621 2 : body: &mut Value,
2622 2 : ) {
2623 : let Peer {
2624 2 : st,
2625 2 : tenant,
2626 2 : headers,
2627 2 : ctx_url,
2628 2 : reg,
2629 2 : } = peer;
2630 : // One read per 100 ids, sequential; a peer map is capped at
2631 : // the broker's limit, so this is a few reads at most
2632 : const CHUNK: usize = 100;
2633 2 : let Some(map) = body.get_mut("entityMap").and_then(Value::as_object_mut) else {
2634 0 : return;
2635 : };
2636 2 : let listed: Vec<String> = map.keys().cloned().collect();
2637 2 : let mut kept = std::collections::HashSet::new();
2638 2 : if reg.query_op().is_some() {
2639 2 : for ids in listed.chunks(CHUNK) {
2640 2 : let mut query = vec![
2641 2 : ("id".to_owned(), ids.join(",")),
2642 2 : ("limit".to_owned(), ids.len().to_string()),
2643 : ];
2644 2 : if let Some(t) = type_param {
2645 2 : query.push(("type".to_owned(), t.clone()));
2646 2 : }
2647 2 : let ((status, found, _), actx) = forward_in(
2648 2 : st,
2649 2 : reqwest::Method::GET,
2650 2 : format!("{}/ngsi-ld/v1/entities", reg.endpoint),
2651 2 : &query,
2652 2 : headers,
2653 2 : tenant,
2654 2 : reg,
2655 2 : ctx_url,
2656 2 : None,
2657 : )
2658 2 : .await;
2659 2 : if !(200..300).contains(&status) {
2660 0 : continue;
2661 2 : }
2662 4 : for e in found.as_array().into_iter().flatten() {
2663 4 : let Some(id) = e.get("id").and_then(Value::as_str) else {
2664 0 : continue;
2665 : };
2666 6 : if ids.iter().any(|i| i == id)
2667 4 : && import_entity(e, reg, actx.as_deref().unwrap_or(ctx))
2668 4 : .is_some_and(|doc| antares_ql::eval::eval_q(q, &doc, ctx, &|_| None))
2669 2 : {
2670 2 : kept.insert(id.to_owned());
2671 2 : }
2672 : }
2673 : }
2674 0 : }
2675 4 : map.retain(|id, _| kept.contains(id));
2676 2 : }
2677 :
2678 : /// 5.7.4.4: forward the temporal query to matching registrations that
2679 : /// support the queryTemporal operation; registrations without it are not
2680 : /// contacted. Returns (auxiliary, expanded doc) pairs.
2681 956 : pub async fn fed_query_temporal(
2682 956 : st: &AppState,
2683 956 : tenant: &TenantId,
2684 956 : headers: &HeaderMap,
2685 956 : ctx: &Context,
2686 956 : params: &HashMap<String, String>,
2687 956 : warnings: &mut Vec<String>,
2688 956 : proxies: &mut Vec<FedReg>,
2689 956 : ) -> Result<Vec<(bool, Value)>, NgsiError> {
2690 956 : let mut spec = query_spec(ctx, params);
2691 956 : spec.temporal = crate::temporalq::TemporalQ::from_params(params, false)
2692 956 : .ok()
2693 956 : .flatten();
2694 956 : let ctx_url = ctx_link_url(headers, &ctx.source);
2695 956 : let ctx_url = &ctx_url;
2696 956 : let matched = matching_regs(st, tenant, &spec, ctx, headers).await?;
2697 956 : proxies.extend(matched.iter().filter(|r| r.is_proxy()).cloned());
2698 956 : let regs: Vec<FedReg> = matched
2699 956 : .into_iter()
2700 956 : .filter(|reg| reg.supports("queryTemporal"))
2701 956 : .collect();
2702 956 : let fetched = fan_out(regs, move |reg| async move {
2703 22 : let mut query = temporal_window(params);
2704 66 : for k in ["type", "id", "idPattern"] {
2705 66 : if let Some(v) = params.get(k) {
2706 22 : query.push((k.into(), v.clone()));
2707 44 : }
2708 : }
2709 : // 5.7.4.4 mirrors 5.7.2.4: with split entities the value/geo/scope
2710 : // filters are stripped from the forward and applied on the
2711 : // aggregate; a registered jsonldContext cannot recompact q/scopeQ
2712 : // terms, so push-down is skipped there too.
2713 22 : if !split_entities(params) && !has_reg_context(®) {
2714 120 : for k in [
2715 20 : "q",
2716 20 : "georel",
2717 20 : "geometry",
2718 20 : "coordinates",
2719 20 : "geoproperty",
2720 20 : "scopeQ",
2721 20 : ] {
2722 120 : if let Some(v) = params.get(k).filter(|v| forwardable(k, v)) {
2723 4 : query.push((k.into(), v.clone()));
2724 116 : }
2725 : }
2726 2 : }
2727 22 : if let Some(a) = scope_attrs(®, ctx) {
2728 4 : query.push(a);
2729 18 : } else if let Some(a) = params.get("attrs") {
2730 0 : query.push(("attrs".into(), a.clone()));
2731 18 : }
2732 22 : let ((status, body, peer_warns), actx) = forward_in(
2733 22 : st,
2734 22 : reqwest::Method::GET,
2735 22 : format!("{}/ngsi-ld/v1/temporal/entities", reg.endpoint),
2736 22 : &query,
2737 22 : headers,
2738 22 : tenant,
2739 22 : ®,
2740 22 : ctx_url,
2741 22 : None,
2742 : )
2743 22 : .await;
2744 22 : (reg, status, body, peer_warns, actx)
2745 44 : })
2746 956 : .await;
2747 956 : let mut out = Vec::new();
2748 956 : for (reg, status, body, peer_warns, actx) in fetched {
2749 22 : if !usable_payload(st, tenant, status, &body, peer_warns, warnings) {
2750 0 : continue;
2751 22 : }
2752 22 : if let Value::Array(a) = &body {
2753 : // Same scope gate as the non-temporal query fan-out.
2754 22 : for c in a
2755 22 : .iter()
2756 22 : .filter(|c| in_scope(®, &spec, actx.as_deref().unwrap_or(ctx), c))
2757 : {
2758 16 : match import_temporal(c, ®, actx.as_deref().unwrap_or(ctx)) {
2759 16 : Some(doc) => out.push((reg.mode == "auxiliary", doc)),
2760 0 : None => warnings.push(warning(
2761 : 111,
2762 0 : &alias_for(&st.host_alias, tenant),
2763 0 : "the payload of the response was invalid",
2764 : )),
2765 : }
2766 : }
2767 0 : }
2768 : }
2769 956 : Ok(out)
2770 956 : }
2771 :
2772 : /// Merge federated docs into a local candidate set (keyed by id). Local docs
2773 : /// win; non-aux remote attrs merge before aux ones.
2774 3011 : pub fn merge_candidates(local: Vec<Value>, fed: Vec<(bool, Value)>) -> Vec<Value> {
2775 3011 : let mut order: Vec<String> = Vec::new();
2776 3011 : let mut by_id: HashMap<String, Value> = HashMap::new();
2777 33560 : for doc in local {
2778 33560 : if let Some(id) = doc.get("id").and_then(Value::as_str) {
2779 33560 : order.push(id.to_owned());
2780 33560 : by_id.insert(id.to_owned(), doc);
2781 33560 : }
2782 : }
2783 6022 : for aux_pass in [false, true] {
2784 6022 : for (aux, doc) in &fed {
2785 1084 : if *aux != aux_pass {
2786 542 : continue;
2787 542 : }
2788 542 : let Some(id) = doc.get("id").and_then(Value::as_str) else {
2789 0 : continue;
2790 : };
2791 542 : match by_id.get_mut(id) {
2792 26 : Some(base) => merge_docs(base, doc, *aux),
2793 516 : None => {
2794 516 : order.push(id.to_owned());
2795 516 : by_id.insert(id.to_owned(), doc.clone());
2796 516 : }
2797 : }
2798 : }
2799 : }
2800 3011 : order
2801 3011 : .into_iter()
2802 34076 : .filter_map(|id| by_id.remove(&id))
2803 3011 : .collect()
2804 3011 : }
2805 :
2806 : // ---------- distributed writes ----------
2807 :
2808 : /// Outcome of one part of a distributed write.
2809 : #[derive(Clone, Debug, Default)]
2810 : pub struct Part {
2811 : /// The status the local write or the source answered.
2812 : pub status: u16,
2813 : pub detail: String,
2814 : /// 5.2.17 BatchEntityError registrationId: the Context Source
2815 : /// Registration a forwarded part went to; `None` for the local part.
2816 : pub registration: Option<String>,
2817 : /// The part went to an exclusive or redirect registration (6.3.17).
2818 : pub proxy: bool,
2819 : /// A source's 207 UpdateResult (5.2.18), Attribute names expanded.
2820 : pub update: Option<RemoteUpdate>,
2821 : /// An exclusive registration refused the operation ([`conflict_part`]): a 409 Conflict, not
2822 : /// AlreadyExists.
2823 : pub conflict: bool,
2824 : }
2825 :
2826 : /// 5.2.18 UpdateResult a source answered for a forwarded attribute write.
2827 : #[derive(Clone, Debug, Default, PartialEq)]
2828 : pub struct RemoteUpdate {
2829 : pub updated: Vec<String>,
2830 : /// (Attribute IRI, reason)
2831 : pub not_updated: Vec<(String, String)>,
2832 : }
2833 :
2834 : impl Part {
2835 : /// A part decided by this broker itself.
2836 498 : pub fn local(status: u16, detail: impl Into<String>) -> Self {
2837 498 : Part {
2838 498 : status,
2839 498 : detail: detail.into(),
2840 498 : ..Part::default()
2841 498 : }
2842 498 : }
2843 1606 : pub fn ok(&self) -> bool {
2844 : // 207 from a forwarded source is a partial failure, not a success
2845 1606 : (200..300).contains(&self.status) && self.status != 207
2846 1606 : }
2847 : /// The status this part answers alone. 6.3.17 p.278: for a proxied
2848 : /// (exclusive/redirect) source the error vocabulary is fixed: 508 loop,
2849 : /// 504 timeout, 404 not found, and "502 Bad Gateway - if the single
2850 : /// forwarded request fails for any other reason such as the Context
2851 : /// Broker itself having insufficient access rights". 409 keeps
2852 : /// AlreadyExists semantics (the peer speaks NGSI-LD) and a 207 stays the
2853 : /// partial verdict it is; a 401/403, a peer's 500/503 or a 400 on the
2854 : /// inter-broker request surfaces as 502. Inside a 207 each error keeps
2855 : /// the source's own status, the most informative one.
2856 146 : pub fn alone_status(&self) -> u16 {
2857 146 : if self.proxy
2858 42 : && !(200..300).contains(&self.status)
2859 42 : && !matches!(self.status, 207 | 404 | 409 | 504 | 508)
2860 : {
2861 12 : 502
2862 : } else {
2863 134 : self.status
2864 : }
2865 146 : }
2866 : /// The ProblemDetails type and title of a failed part. 5.6.1.4/5.6.2…:
2867 : /// an unsupported-operation part is the Conflict error type; an
2868 : /// entity-exists part stays AlreadyExists.
2869 356 : fn problem(&self, status: u16) -> (&'static str, &'static str) {
2870 74 : match status {
2871 58 : 409 if self.conflict => ("Conflict", "Conflict"),
2872 16 : 409 => ("AlreadyExists", "Conflict"),
2873 184 : 404 => ("ResourceNotFound", "Not Found"),
2874 98 : _ => ("InternalError", "Error"),
2875 : }
2876 356 : }
2877 : }
2878 :
2879 : /// One failed part answered alone, as the ProblemDetails of its status.
2880 136 : pub fn part_error(p: &Part, tenant: &TenantId) -> Response {
2881 136 : problem_response(p, tenant, false)
2882 136 : }
2883 :
2884 : /// The failure of the single registered source of a distributed read (6.3.17
2885 : /// p.278), naming the Context Source Registration it was asked through.
2886 10 : pub fn source_error(p: &Part, tenant: &TenantId) -> Response {
2887 10 : problem_response(p, tenant, true)
2888 10 : }
2889 :
2890 146 : fn problem_response(p: &Part, tenant: &TenantId, name_registration: bool) -> Response {
2891 146 : let code = p.alone_status();
2892 146 : let status = StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY);
2893 146 : let (etype, title) = p.problem(code);
2894 146 : let mut body = json!({
2895 146 : "type": format!("https://uri.etsi.org/ngsi-ld/errors/{etype}"),
2896 146 : "title": title,
2897 146 : "detail": p.detail,
2898 146 : "status": code,
2899 : });
2900 146 : if let (true, Some(rid), Some(o)) = (name_registration, &p.registration, body.as_object_mut()) {
2901 10 : o.insert("registrationId".into(), Value::String(rid.clone()));
2902 136 : }
2903 146 : let mut resp = (
2904 146 : status,
2905 146 : [(axum::http::header::CONTENT_TYPE, "application/json")],
2906 146 : axum::Json(body),
2907 146 : )
2908 146 : .into_response();
2909 146 : echo_tenant(tenant, &mut resp);
2910 146 : resp
2911 146 : }
2912 :
2913 : /// Combine local + forwarded parts of a write on Entity `id` (`None` for a
2914 : /// set operation such as Purge) (6.3.17/6.4.3.1): all-success ⇒ `ok`, single failing part ⇒ its own
2915 : /// error, mixed ⇒ 207 Multi-Status with a BatchOperationResult.
2916 508 : pub fn combine(
2917 508 : profile: crate::Profile,
2918 508 : id: Option<&str>,
2919 508 : mut parts: Vec<Part>,
2920 508 : ok: Response,
2921 508 : tenant: &TenantId,
2922 508 : ) -> Response {
2923 : // 6.3.17 calls a source's 404 not abnormal only for HTTP GET; every
2924 : // part here is an unsafe write, whose error response "should be as
2925 : // informative as possible": a 404 beside a success stays in the 207
2926 : // (ETSI D002_02_01_inc, D002_02_02_inc). 404 everywhere is the one 404.
2927 : // ANTARES_FED_WRITE_SOURCE_404=ignore reads the 404 as "no data" for
2928 : // writes too (forge all-fixes, Coraine): beside a success it drops out.
2929 508 : if profile.fed_write_ignore_404 && parts.iter().any(Part::ok) {
2930 32 : parts.retain(|p| p.status != 404);
2931 494 : }
2932 508 : if parts.iter().all(Part::ok) {
2933 238 : return ok;
2934 270 : }
2935 270 : if parts.len() == 1 || parts.iter().all(|p| p.status == 404) {
2936 112 : let Some(p) = parts.first() else {
2937 0 : return ok;
2938 : };
2939 112 : return part_error(p, tenant);
2940 158 : }
2941 : // TS 104 176 7.2.3.2: the 207 body is a BatchOperationResult. 5.2.16
2942 : // success: "Array of Entity IDs corresponding to the Elements that were
2943 : // successfully treated"; 5.2.17 BatchEntityError: entityId "Entity ID
2944 : // corresponding to the Entity in error", registrationId "Registration Id
2945 : // corresponding to a failed distributed operation".
2946 158 : let errors: Vec<Value> = parts
2947 158 : .iter()
2948 316 : .filter(|p| !p.ok())
2949 210 : .map(|p| {
2950 210 : let (etype, title) = p.problem(p.status);
2951 210 : let mut e = json!({
2952 210 : "error": {
2953 210 : "status": p.status,
2954 210 : "type": format!("https://uri.etsi.org/ngsi-ld/errors/{etype}"),
2955 210 : "title": title,
2956 210 : "detail": p.detail,
2957 : }
2958 : });
2959 210 : if let Some(o) = e.as_object_mut() {
2960 210 : if let Some(id) = id {
2961 208 : o.insert("entityId".into(), Value::String(id.to_owned()));
2962 208 : }
2963 210 : if let Some(rid) = &p.registration {
2964 106 : o.insert("registrationId".into(), Value::String(rid.clone()));
2965 108 : }
2966 0 : }
2967 210 : e
2968 210 : })
2969 158 : .collect();
2970 158 : let success: Vec<&str> = id
2971 158 : .filter(|_| parts.iter().any(Part::ok))
2972 158 : .into_iter()
2973 158 : .collect();
2974 158 : let body = json!({"success": success, "errors": errors});
2975 158 : let mut resp = (
2976 158 : StatusCode::MULTI_STATUS,
2977 158 : [(axum::http::header::CONTENT_TYPE, "application/json")],
2978 158 : axum::Json(body),
2979 158 : )
2980 158 : .into_response();
2981 158 : echo_tenant(tenant, &mut resp);
2982 158 : resp
2983 508 : }
2984 :
2985 : /// 4.3.6.1: "It is the responsibility of the Context Broker to respect the
2986 : /// registration parameters when issuing distributed requests. […] Ultimately,
2987 : /// all constraints specified in the registration shall be respected."
2988 : /// Reduce a compacted entity/fragment to the members a registration covers
2989 : /// (plus id/type); returns None if no attribute member remains.
2990 456 : pub fn reduce_to_scope(obj: &Map<String, Value>, reg: &FedReg, ctx: &Context) -> Option<Value> {
2991 : // an item outside the registration's EntityInfo ids/types is not this
2992 : // source's data at all — nothing of it may be forwarded there
2993 456 : if !reg.covers_item(obj, ctx) {
2994 8 : return None;
2995 448 : }
2996 448 : let Some(_) = ®.attrs else {
2997 348 : let mut full = obj.clone();
2998 348 : full.remove("@context");
2999 348 : return Some(Value::Object(full));
3000 : };
3001 100 : let mut out = Map::new();
3002 100 : let mut any = false;
3003 424 : for (k, v) in obj {
3004 424 : if k == "@context" {
3005 68 : continue;
3006 356 : }
3007 356 : if ["id", "type", "scope"].contains(&k.as_str()) {
3008 182 : out.insert(k.clone(), v.clone());
3009 182 : continue;
3010 174 : }
3011 174 : if reg.covers_attr(&ctx.expand_key(k)) {
3012 98 : out.insert(k.clone(), v.clone());
3013 98 : any = true;
3014 98 : }
3015 : }
3016 100 : any.then_some(Value::Object(out))
3017 456 : }
3018 :
3019 84 : fn without_id(obj: &Map<String, Value>) -> Map<String, Value> {
3020 84 : let mut o = obj.clone();
3021 84 : o.remove("id");
3022 84 : o
3023 84 : }
3024 :
3025 : /// 508 Loop Detected (6.3.17): the inbound Via chain already names us and a
3026 : /// registration would forward the operation right back.
3027 70 : pub fn loop_508(tenant: &TenantId) -> Response {
3028 70 : let body = json!({
3029 70 : "type": "https://uri.etsi.org/ngsi-ld/errors/InternalError",
3030 70 : "title": "Loop Detected",
3031 70 : "detail": "the Via chain already contains this broker",
3032 70 : "status": 508,
3033 : });
3034 70 : let mut resp = (
3035 70 : StatusCode::LOOP_DETECTED,
3036 70 : [(axum::http::header::CONTENT_TYPE, "application/json")],
3037 70 : axum::Json(body),
3038 70 : )
3039 70 : .into_response();
3040 70 : echo_tenant(tenant, &mut resp);
3041 70 : resp
3042 70 : }
3043 :
3044 : /// What a distributed write does with its registrations once the 6.3.18
3045 : /// loop rule has spoken: forward to these, or answer with the chain's own
3046 : /// response and touch nothing locally.
3047 : pub enum WritePlan {
3048 : /// The non-auxiliary registrations that match the operation's entity
3049 : /// specification, `Via` chain already applied; empty means local only.
3050 : Forward(Vec<FedReg>),
3051 : /// The loop chain's answer (Table 6.3.18-2, or 508 past the hop cap):
3052 : /// the operation ends here, before anything local happens.
3053 : Answered(Box<Response>),
3054 : }
3055 :
3056 : /// The prologue every distributed write shares (4.3.6, 6.3.18): the
3057 : /// registrations of [`write_regs`] with [`handle_via_loop`] applied, so
3058 : /// no operation re-derives the pair and the two cannot disagree.
3059 35886 : pub async fn write_plan(
3060 35886 : st: &AppState,
3061 35886 : tenant: &TenantId,
3062 35886 : spec: &crate::registry::CsrSpec,
3063 35886 : ctx: &Context,
3064 35886 : params: &HashMap<String, String>,
3065 35886 : headers: &HeaderMap,
3066 35886 : ) -> Result<WritePlan, NgsiError> {
3067 35886 : let mut regs = write_regs(st, tenant, spec, ctx, params, headers).await?;
3068 : Ok(
3069 35884 : match handle_via_loop(
3070 35884 : headers,
3071 35884 : &alias_for(&st.host_alias, tenant),
3072 35884 : tenant,
3073 35884 : &mut regs,
3074 35884 : ) {
3075 46 : Some(answer) => WritePlan::Answered(Box::new(answer)),
3076 35838 : None => WritePlan::Forward(regs),
3077 : },
3078 : )
3079 35886 : }
3080 :
3081 : /// [`write_plan`] for the registrations of one Entity id.
3082 4996 : pub async fn write_plan_for_id(
3083 4996 : st: &AppState,
3084 4996 : tenant: &TenantId,
3085 4996 : id: &str,
3086 4996 : ctx: &Context,
3087 4996 : params: &HashMap<String, String>,
3088 4996 : headers: &HeaderMap,
3089 4996 : ) -> Result<WritePlan, NgsiError> {
3090 4996 : let spec = crate::registry::CsrSpec {
3091 4996 : ids: Some(vec![id.to_owned()]),
3092 4996 : ..Default::default()
3093 4996 : };
3094 4996 : write_plan(st, tenant, &spec, ctx, params, headers).await
3095 4996 : }
3096 :
3097 : /// Where a fragment write `op` goes: every registration that supports it,
3098 : /// with the share of `obj` its scope covers, and the Conflict part of each
3099 : /// proxy registration that does not (an inclusive one is not forwarded).
3100 168 : pub fn scoped_fragments(
3101 168 : regs: &[FedReg],
3102 168 : op: &str,
3103 168 : obj: &Map<String, Value>,
3104 168 : ctx: &Context,
3105 168 : ) -> (Vec<Part>, Vec<(FedReg, Value)>) {
3106 168 : let mut conflicts = Vec::new();
3107 168 : let mut fwd = Vec::new();
3108 174 : for reg in regs {
3109 174 : if !reg.supports(op) {
3110 92 : if reg.is_proxy() {
3111 16 : conflicts.push(conflict_part(op, reg));
3112 76 : }
3113 92 : continue;
3114 82 : }
3115 82 : if let Some(frag) = reduce_to_scope(obj, reg, ctx) {
3116 82 : fwd.push((reg.clone(), frag));
3117 82 : }
3118 : }
3119 168 : (conflicts, fwd)
3120 168 : }
3121 :
3122 : /// 4.3.6.2: "Auxiliary distributed operations are limited to context
3123 : /// information consumption operations (see clause 5.7)" — so a write op
3124 : /// only ever considers non-auxiliary matching registrations.
3125 35890 : pub async fn write_regs(
3126 35890 : st: &AppState,
3127 35890 : tenant: &TenantId,
3128 35890 : spec: &crate::registry::CsrSpec,
3129 35890 : ctx: &Context,
3130 35890 : params: &HashMap<String, String>,
3131 35890 : headers: &HeaderMap,
3132 35890 : ) -> Result<Vec<FedReg>, NgsiError> {
3133 35890 : if !active(params) {
3134 224 : return Ok(Vec::new());
3135 35666 : }
3136 35666 : Ok(matching_regs(st, tenant, spec, ctx, headers)
3137 35666 : .await?
3138 35664 : .into_iter()
3139 35664 : .filter(|r| r.mode != "auxiliary")
3140 35664 : .collect())
3141 35890 : }
3142 :
3143 : /// Execute one forwarded write and turn it into a Part.
3144 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
3145 466 : pub async fn forward_part(
3146 466 : st: &AppState,
3147 466 : method: reqwest::Method,
3148 466 : url: String,
3149 466 : query: &[(String, String)],
3150 466 : headers: &HeaderMap,
3151 466 : tenant: &TenantId,
3152 466 : reg: &FedReg,
3153 466 : ctx_url: &str,
3154 466 : body: Option<Value>,
3155 466 : ) -> Part {
3156 466 : let (status, answer, _) = forward(
3157 466 : st,
3158 466 : method,
3159 466 : url.clone(),
3160 466 : query,
3161 466 : headers,
3162 466 : tenant,
3163 466 : reg,
3164 466 : ctx_url,
3165 466 : body,
3166 : )
3167 466 : .await;
3168 : // 6.3.17: "the error response should be as informative as possible" —
3169 : // informative about the operation, not about the deployment. The part is
3170 : // named by its Context Source Registration id (5.2.9), which the client
3171 : // can already read from /csourceRegistrations; the registered endpoint
3172 : // is internal topology and stays in the log, so that a client able to
3173 : // provoke a partial failure cannot enumerate the peers.
3174 466 : tracing::debug!(
3175 : "distributed operation to {} returned {status}",
3176 0 : antares_notifier::redact_userinfo(&url)
3177 : );
3178 466 : let detail = format!(
3179 : "distributed operation to registration {} returned {status}",
3180 : reg.reg_id
3181 : );
3182 : Part {
3183 466 : status,
3184 466 : detail,
3185 466 : registration: Some(reg.reg_id.clone()),
3186 466 : proxy: reg.is_proxy(),
3187 466 : update: (status == 207).then(|| remote_update(&answer)).flatten(),
3188 : conflict: false,
3189 : }
3190 466 : }
3191 :
3192 : /// A source's 207 body read as a 5.2.18 UpdateResult, names as the source
3193 : /// wrote them; `None` when it is not one.
3194 20 : fn remote_update(body: &Value) -> Option<RemoteUpdate> {
3195 20 : let o = body.as_object()?;
3196 8 : let names = |v: Option<&Value>| -> Option<Vec<Value>> {
3197 8 : match v {
3198 0 : None => Some(Vec::new()),
3199 8 : Some(Value::Array(a)) => Some(a.clone()),
3200 0 : Some(_) => None,
3201 : }
3202 8 : };
3203 4 : let updated = names(o.get("updated"))?
3204 4 : .iter()
3205 6 : .map(|n| n.as_str().map(str::to_owned))
3206 4 : .collect::<Option<Vec<_>>>()?;
3207 4 : let not_updated = names(o.get("notUpdated"))?
3208 4 : .iter()
3209 4 : .map(|d| {
3210 2 : let name = d.get("attributeName")?.as_str()?.to_owned();
3211 2 : let reason = d.get("reason").and_then(Value::as_str).unwrap_or_default();
3212 2 : Some((name, reason.to_owned()))
3213 2 : })
3214 4 : .collect::<Option<Vec<_>>>()?;
3215 4 : Some(RemoteUpdate {
3216 4 : updated,
3217 4 : not_updated,
3218 4 : })
3219 20 : }
3220 :
3221 : /// Forward one attribute-level write to every matching registration. A
3222 : /// `fragment` body (Append, Update Attributes) is an Attribute map, reduced
3223 : /// per registration to the Attributes it covers: 5.6.3.4 "If an exclusive or
3224 : /// redirect Context Source Registration matches against the input data, the
3225 : /// Attributes from matching input data are forwarded", and 4.3.6.1 "all
3226 : /// constraints specified in the registration shall be respected".
3227 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
3228 166 : pub async fn fed_attr_parts(
3229 166 : st: &AppState,
3230 166 : headers: &HeaderMap,
3231 166 : tenant: &TenantId,
3232 166 : ctx: &Context,
3233 166 : regs: &[FedReg],
3234 166 : op: &str,
3235 166 : method: reqwest::Method,
3236 166 : path: &str,
3237 166 : query: &[(String, String)],
3238 166 : body: Option<Value>,
3239 166 : fragment: bool,
3240 166 : ) -> Vec<Part> {
3241 166 : let ctx_url = ctx_link_url(headers, &ctx.source);
3242 166 : let mut parts = Vec::new();
3243 196 : for reg in regs {
3244 : // 5.6.2.4 (and the sibling attribute operations): a proxy-mode
3245 : // registration not supporting the operation is an error of type
3246 : // Conflict and is never contacted; an inclusive one is simply not
3247 : // forwarded.
3248 196 : if !reg.supports(op) {
3249 8 : if reg.is_proxy() {
3250 6 : parts.push(conflict_part(op, reg));
3251 6 : } else {
3252 2 : parts.push(not_forwarded(format!("{op} not supported")));
3253 2 : }
3254 8 : continue;
3255 188 : }
3256 188 : let body = match (&body, fragment) {
3257 : // The path names the Entity, and `write_plan` matched the
3258 : // registrations on it; an `id` inside the fragment names
3259 : // nothing (the ETSI payloads carry "urn:ngsi-ld:Vehicle:randomUUID"),
3260 : // so it neither decides the scope nor goes to the source.
3261 84 : (Some(Value::Object(o)), true) => match reduce_to_scope(&without_id(o), reg, ctx) {
3262 84 : Some(b) => Some(b),
3263 : None => {
3264 0 : parts.push(not_forwarded("no Attribute of the registration".into()));
3265 0 : continue;
3266 : }
3267 : },
3268 104 : _ => body.clone(),
3269 : };
3270 188 : let mut part = forward_part(
3271 188 : st,
3272 188 : method.clone(),
3273 188 : format!("{}/ngsi-ld/v1{path}", reg.endpoint),
3274 188 : query,
3275 188 : headers,
3276 188 : tenant,
3277 188 : reg,
3278 188 : &ctx_url,
3279 188 : body,
3280 188 : )
3281 188 : .await;
3282 : // the source answered in the Context it was sent (ctx_url)
3283 188 : if let Some(u) = part.update.as_mut() {
3284 6 : for n in u.updated.iter_mut() {
3285 6 : *n = ctx.expand_key(n);
3286 6 : }
3287 4 : for (n, _) in u.not_updated.iter_mut() {
3288 2 : *n = ctx.expand_key(n);
3289 2 : }
3290 184 : }
3291 188 : parts.push(part);
3292 : }
3293 166 : parts
3294 166 : }
3295 :
3296 : /// status 0 = "not forwarded" sentinel: keeps the parts list 1:1 with regs
3297 : /// (combine_attr_parts zips them) without counting as success or failure.
3298 2 : fn not_forwarded(why: String) -> Part {
3299 2 : Part::local(0, format!("not forwarded: {why}"))
3300 2 : }
3301 :
3302 : /// Conflict part for an exclusive registration that does not accept the op.
3303 64 : pub fn conflict_part(op: &str, reg: &FedReg) -> Part {
3304 64 : Part {
3305 64 : status: 409,
3306 64 : detail: format!("exclusive registration does not accept {op}"),
3307 64 : registration: Some(reg.reg_id.clone()),
3308 64 : proxy: true,
3309 64 : update: None,
3310 64 : conflict: true,
3311 64 : }
3312 64 : }
3313 :
3314 : /// Remove proxy-covered attributes from an EXPANDED fragment so the local
3315 : /// write never stores exclusively/redirect-registered data (4.3.6.3).
3316 13590 : pub fn strip_covered_expanded(fragment: &Value, regs: &[FedReg]) -> Value {
3317 13590 : let mut f = fragment.clone();
3318 13590 : if let Some(o) = f.as_object_mut() {
3319 13590 : let covered: Vec<String> = o
3320 13590 : .keys()
3321 51700 : .filter(|k| {
3322 64 : !matches!(
3323 51700 : k.as_str(),
3324 51700 : "id" | "type" | "scope" | "createdAt" | "modifiedAt"
3325 51636 : ) && regs.iter().any(|r| r.is_proxy() && r.covers_attr(k))
3326 51700 : })
3327 13590 : .cloned()
3328 13590 : .collect();
3329 13590 : for k in covered {
3330 76 : o.remove(&k);
3331 76 : }
3332 0 : }
3333 13590 : f
3334 13590 : }
3335 :
3336 : /// 4.3.6.3 (TS 104 175 9.3.3): with an exclusive or redirect registration
3337 : /// the "Context Broker itself is not permitted to hold context data about the
3338 : /// registered Entities and Attributes locally (thus all registered context
3339 : /// data is obtained from the external registered sources)". Drop from a
3340 : /// local EXPANDED document the Attributes such a registration claims for it.
3341 3192 : pub fn drop_proxied_local(doc: &mut Value, proxies: &[FedReg], ctx: &Context) {
3342 3192 : let Some(o) = doc.as_object() else {
3343 0 : return;
3344 : };
3345 3192 : let claims: Vec<FedReg> = proxies
3346 3192 : .iter()
3347 3192 : .filter(|r| r.is_proxy() && r.covers_item(o, ctx))
3348 3192 : .cloned()
3349 3192 : .collect();
3350 3192 : if !claims.is_empty() {
3351 4 : *doc = strip_covered_expanded(doc, &claims);
3352 3188 : }
3353 3192 : }
3354 :
3355 : /// Strip the members proxied registrations cover from a compacted object;
3356 : /// returns (remainder, had_attrs_left).
3357 338 : pub fn strip_proxied(
3358 338 : obj: &Map<String, Value>,
3359 338 : proxies: &[&FedReg],
3360 338 : ctx: &Context,
3361 338 : ) -> (Map<String, Value>, bool) {
3362 338 : let mut out = Map::new();
3363 338 : let mut any_attr = false;
3364 1376 : for (k, v) in obj {
3365 1376 : if k == "@context" {
3366 180 : continue;
3367 1196 : }
3368 1196 : if ["id", "type", "scope"].contains(&k.as_str()) {
3369 688 : out.insert(k.clone(), v.clone());
3370 688 : continue;
3371 508 : }
3372 508 : let iri = ctx.expand_key(k);
3373 : // a proxy only owns the attribute if this ITEM is within its
3374 : // EntityInfo constraints (4.3.6.1) — a type-scoped registration
3375 : // must not strip attributes from an unrelated entity
3376 508 : if proxies
3377 508 : .iter()
3378 508 : .any(|r| r.covers_item(obj, ctx) && r.covers_attr(&iri))
3379 : {
3380 294 : continue;
3381 214 : }
3382 214 : out.insert(k.clone(), v.clone());
3383 214 : any_attr = true;
3384 : }
3385 338 : (out, any_attr)
3386 338 : }
3387 :
3388 : #[cfg(test)]
3389 : mod tests {
3390 : use super::*;
3391 : use serde_json::json;
3392 :
3393 92 : fn hdrs(via: Option<&str>) -> HeaderMap {
3394 92 : let mut h = HeaderMap::new();
3395 92 : if let Some(v) = via {
3396 60 : h.insert("via", v.parse().expect("via"));
3397 60 : }
3398 92 : h
3399 92 : }
3400 :
3401 : /// 6.3.17: on a write a 404 beside a success is reported in the 207
3402 : /// (ETSI D002_02_01_inc local 204 + source 404, D002_02_02_inc local 404
3403 : /// + source 204); 404 on every part is one 404.
3404 : #[test]
3405 4 : fn a_404_beside_a_success_is_a_multi_status_on_a_write() {
3406 4 : let t = antares_model::TenantId::new(TenantId::DEFAULT).expect("tenant");
3407 32 : let part = |status: u16| Part::local(status, String::new());
3408 16 : let ok = || StatusCode::NO_CONTENT.into_response();
3409 4 : assert_eq!(
3410 4 : combine(
3411 4 : crate::Profile::default(),
3412 4 : Some("urn:ngsi-ld:V:1"),
3413 4 : vec![part(204), part(404)],
3414 4 : ok(),
3415 4 : &t
3416 4 : )
3417 4 : .status(),
3418 : 207
3419 : );
3420 4 : assert_eq!(
3421 4 : combine(
3422 4 : crate::Profile::default(),
3423 4 : Some("urn:ngsi-ld:V:1"),
3424 4 : vec![part(404), part(204)],
3425 4 : ok(),
3426 4 : &t
3427 4 : )
3428 4 : .status(),
3429 : 207
3430 : );
3431 4 : assert_eq!(
3432 4 : combine(
3433 4 : crate::Profile::default(),
3434 4 : Some("urn:ngsi-ld:V:1"),
3435 4 : vec![part(404), part(404)],
3436 4 : ok(),
3437 4 : &t
3438 4 : )
3439 4 : .status(),
3440 : 404
3441 : );
3442 4 : assert_eq!(
3443 4 : combine(
3444 4 : crate::Profile::default(),
3445 4 : Some("urn:ngsi-ld:V:1"),
3446 4 : vec![part(204), part(500)],
3447 4 : ok(),
3448 4 : &t
3449 4 : )
3450 4 : .status(),
3451 : 207
3452 : );
3453 4 : }
3454 :
3455 : /// ANTARES_FED_WRITE_SOURCE_404=ignore: a 404 beside a success
3456 : /// drops out (forge all-fixes D002_02_01_inc/D002_02_02_inc, Coraine
3457 : /// distop_delete_inclusive_source_404 step 03); 404 everywhere stays
3458 : /// one 404 and any other failure stays in the 207.
3459 : #[test]
3460 4 : fn ignore_profile_reads_a_404_beside_a_success_as_no_data() {
3461 4 : let t = antares_model::TenantId::new(TenantId::DEFAULT).expect("tenant");
3462 36 : let part = |status: u16| Part::local(status, String::new());
3463 16 : let ok = || StatusCode::NO_CONTENT.into_response();
3464 4 : let p = crate::Profile {
3465 4 : fed_write_ignore_404: true,
3466 4 : ..crate::Profile::default()
3467 4 : };
3468 4 : assert_eq!(
3469 4 : combine(
3470 4 : p,
3471 4 : Some("urn:ngsi-ld:V:1"),
3472 4 : vec![part(204), part(404)],
3473 4 : ok(),
3474 4 : &t
3475 4 : )
3476 4 : .status(),
3477 : 204
3478 : );
3479 4 : assert_eq!(
3480 4 : combine(
3481 4 : p,
3482 4 : Some("urn:ngsi-ld:V:1"),
3483 4 : vec![part(404), part(204)],
3484 4 : ok(),
3485 4 : &t
3486 4 : )
3487 4 : .status(),
3488 : 204
3489 : );
3490 4 : assert_eq!(
3491 4 : combine(
3492 4 : p,
3493 4 : Some("urn:ngsi-ld:V:1"),
3494 4 : vec![part(404), part(404)],
3495 4 : ok(),
3496 4 : &t
3497 4 : )
3498 4 : .status(),
3499 : 404
3500 : );
3501 4 : assert_eq!(
3502 4 : combine(
3503 4 : p,
3504 4 : Some("urn:ngsi-ld:V:1"),
3505 4 : vec![part(204), part(404), part(500)],
3506 4 : ok(),
3507 4 : &t
3508 4 : )
3509 4 : .status(),
3510 : 207
3511 : );
3512 4 : }
3513 :
3514 : /// 4.3.6.6: contextSourceInfo carries the headers a source needs to
3515 : /// answer at all (an API key, say). The minimal registration view built
3516 : /// for the subscription operations dropped them, so every forwarded
3517 : /// subscription create, update and delete went out unauthenticated.
3518 : #[test]
3519 4 : fn the_minimal_registration_view_keeps_context_source_info() {
3520 4 : let doc = json!({
3521 4 : "id": "urn:ngsi-ld:ContextSourceRegistration:csi",
3522 4 : "endpoint": "http://peer:9090",
3523 4 : "contextSourceInfo": [
3524 4 : {"key": "X-API-Key", "value": "s3cret"},
3525 4 : {"key": "jsonldContext", "value": "https://example.org/ctx.jsonld"}
3526 : ]
3527 : });
3528 4 : let reg = fed_reg_of("urn:ngsi-ld:ContextSourceRegistration:csi", &doc);
3529 4 : assert_eq!(
3530 : reg.csi,
3531 4 : vec![
3532 4 : ("X-API-Key".to_owned(), "s3cret".to_owned()),
3533 4 : (
3534 4 : "jsonldContext".to_owned(),
3535 4 : "https://example.org/ctx.jsonld".to_owned()
3536 4 : ),
3537 : ]
3538 : );
3539 4 : }
3540 :
3541 : /// 4.17: `type` is one Entity Type Selection. Splitting it on commas
3542 : /// destroys a conjunction, and the shared evaluator (which csource tests
3543 : /// against real registrations) never sees the expression the client sent.
3544 : #[test]
3545 4 : fn the_type_selection_reaches_registration_matching_whole() {
3546 4 : let ctx = antares_jsonld::Loader::new().core();
3547 4 : let mut params = HashMap::new();
3548 4 : params.insert("type".to_owned(), "(Home;Vehicle),Building".to_owned());
3549 4 : let spec = query_spec(&ctx, ¶ms);
3550 4 : assert_eq!(
3551 : spec.types,
3552 4 : Some(vec!["(Home;Vehicle),Building".to_owned()]),
3553 : "the selection travels as one expression"
3554 : );
3555 4 : }
3556 :
3557 : /// 6.6/6.7: which part of a forwarded URL names the Attribute. The
3558 : /// segment ends at the next `/`, so the `value` sub-resource and a 6.7
3559 : /// instanceId are outside it, and a query string is never part of a path.
3560 : #[test]
3561 4 : fn the_path_attribute_segment_is_the_one_between_attrs_and_the_next_slash() {
3562 36 : let name = |u: &str| path_attr_segment(u).map(|(_, n)| n);
3563 4 : assert_eq!(
3564 4 : name("/entities/urn:x/attrs/speed"),
3565 4 : Some("speed".to_owned())
3566 : );
3567 4 : assert_eq!(
3568 4 : name("/entities/urn:x/attrs/speed/value"),
3569 4 : Some("speed".to_owned())
3570 : );
3571 4 : assert_eq!(
3572 4 : name("/temporal/entities/urn:x/attrs/speed/urn:ngsi-ld:instance:1"),
3573 4 : Some("speed".to_owned())
3574 : );
3575 4 : assert_eq!(
3576 4 : name("/entities/urn:x/attrs/speed?type=Vehicle"),
3577 4 : Some("speed".to_owned())
3578 : );
3579 : // percent-decoded, because that is the form an @context expands
3580 4 : assert_eq!(
3581 4 : name("/entities/urn:x/attrs/http%3A%2F%2Fa.example%2Fs"),
3582 4 : Some("http://a.example/s".to_owned())
3583 : );
3584 : // resources that name no Attribute
3585 4 : assert_eq!(name("/entities/urn:x"), None);
3586 4 : assert_eq!(name("/entities/urn:x/attrs"), None);
3587 4 : assert_eq!(name("/entities/urn:x/attrs/"), None);
3588 4 : assert_eq!(name("/entities/urn:x/attrs/?type=V"), None);
3589 : // the range addresses the ENCODED segment, so writing a translated
3590 : // name back over it cannot corrupt the rest of the URL
3591 4 : let url = "/entities/urn:x/attrs/speed/value?q=1";
3592 4 : let (range, _) = path_attr_segment(url).expect("segment");
3593 4 : assert_eq!(&url[range], "speed");
3594 4 : }
3595 :
3596 : /// 4.3.6.6 recompaction where the request @context and the registered one
3597 : /// are the same document is the identity: the Context Source receives the
3598 : /// Attribute Fragment the client sent, sub-Attributes and all. Any other
3599 : /// answer would rewrite a payload the clause only asked to re-spell.
3600 : #[test]
3601 4 : fn an_attribute_fragment_round_trips_through_one_context_unchanged() {
3602 4 : let ctx = antares_jsonld::core_context();
3603 4 : let frag = serde_json::json!({
3604 4 : "type": "Property",
3605 4 : "value": 56,
3606 4 : "source": {"type": "Property", "value": "Speedometer"},
3607 : });
3608 4 : let exp = antares_jsonld::expand_attr_fragment(frag.as_object().expect("obj"), &ctx)
3609 4 : .expect("the fragment expands");
3610 4 : assert_eq!(
3611 4 : antares_jsonld::compact::compact_instance(&exp, &ctx),
3612 : frag,
3613 : "the same @context in and out must change nothing"
3614 : );
3615 4 : }
3616 :
3617 : /// RFC 3986 clause 3.3: `#`, `?` and `/` end a path segment, so a client
3618 : /// id carrying one would re-target the peer's resource. The characters an
3619 : /// NGSI-LD id legitimately uses (`urn:`, `:`, `-`) must survive unchanged
3620 : /// or every forward would address a different entity than the client did.
3621 : #[test]
3622 4 : fn path_segment_encodes_what_would_end_the_segment() {
3623 4 : assert_eq!(
3624 4 : path_segment("urn:ngsi-ld:Vehicle:A4567-W"),
3625 : "urn:ngsi-ld:Vehicle:A4567-W"
3626 : );
3627 4 : assert_eq!(path_segment("urn:x#"), "urn:x%23");
3628 4 : assert_eq!(path_segment("urn:x?q=1"), "urn:x%3Fq=1");
3629 4 : assert_eq!(path_segment("a/b"), "a%2Fb");
3630 : // an id already carrying a percent must not decode twice at the peer
3631 4 : assert_eq!(path_segment("a%2Fb"), "a%252Fb");
3632 : // `.` is unreserved, so dot segments pass through unchanged here —
3633 : // they are refused at the door instead (EntityId::new, check_attr_name)
3634 4 : assert_eq!(path_segment(".."), "..");
3635 : // non-ASCII is percent-encoded per its UTF-8 bytes
3636 4 : assert_eq!(path_segment("é"), "%C3%A9");
3637 4 : }
3638 :
3639 : /// RFC 7230 received-by is a TOKEN compared for equality:
3640 : /// `ends_with` made alias `b1` match pseudonym `sub-b1` (spurious loop)
3641 : /// and could never catch the converse.
3642 : #[test]
3643 4 : fn via_loop_compares_tokens_not_suffixes() {
3644 4 : assert!(via_loop(&hdrs(Some("1.1 b1")), "b1"));
3645 4 : assert!(via_loop(&hdrs(Some("1.1 b2, 1.1 b1")), "b1"));
3646 4 : assert!(via_loop(&hdrs(Some("HTTP/1.1 b1")), "b1"));
3647 4 : assert!(
3648 4 : !via_loop(&hdrs(Some("1.1 sub-b1")), "b1"),
3649 : "suffix must not match — a former suffix-match false positive"
3650 : );
3651 4 : assert!(!via_loop(&hdrs(Some("1.1 b10")), "b1"));
3652 4 : assert!(!via_loop(&hdrs(None), "b1"));
3653 : // malformed element carrying only a pseudonym still detects
3654 4 : assert!(via_loop(&hdrs(Some("b1")), "b1"));
3655 4 : }
3656 :
3657 52 : fn reg(mode: &str) -> FedReg {
3658 52 : FedReg {
3659 52 : reg_id: "urn:ngsi-ld:ContextSourceRegistration:test".into(),
3660 52 : endpoint: "http://peer:9090".into(),
3661 52 : mode: mode.into(),
3662 52 : ops: vec!["federationOps".into()],
3663 52 : attrs: None,
3664 52 : ent_ids: vec![],
3665 52 : ent_types: vec![],
3666 52 : ent_patterns: vec![],
3667 52 : ent_unrestricted: false,
3668 52 : tenant: None,
3669 52 : alias: None,
3670 52 : csi: vec![],
3671 52 : local_only: false,
3672 52 : timeout_ms: None,
3673 52 : cooldown_ms: None,
3674 52 : }
3675 52 : }
3676 :
3677 : /// 5.12 condition 5, response side: a query idPattern was forwarded to a
3678 : /// pattern-scoped registration BECAUSE the two patterns cannot be
3679 : /// compared — so the answer cannot be refused on the registration's
3680 : /// pattern when the CLIENT's own selection admits the id. An id outside
3681 : /// BOTH scopes stays refused (a peer must not inject unrelated entities
3682 : /// that would win on recency).
3683 : #[test]
3684 4 : fn a_response_id_the_query_selects_survives_the_registration_pattern() {
3685 4 : let mut r = reg("inclusive");
3686 4 : r.ent_patterns = vec!["^urn:ngsi-ld:V:sk_bb:.*$".into()];
3687 4 : let spec = crate::registry::CsrSpec {
3688 4 : id_pattern: Some("^urn:ngsi-ld:V:sk_zvolen:.*$".into()),
3689 4 : ..Default::default()
3690 4 : };
3691 4 : assert!(
3692 4 : admits_import(&r, &spec, "urn:ngsi-ld:V:sk_zvolen:7"),
3693 : "the client's own pattern admits the id the peer answered with"
3694 : );
3695 4 : assert!(
3696 4 : admits_import(&r, &spec, "urn:ngsi-ld:V:sk_bb:1"),
3697 : "the registration's own scope still admits"
3698 : );
3699 4 : assert!(
3700 4 : !admits_import(&r, &spec, "urn:ngsi-ld:V:sk_presov:9"),
3701 : "an id outside both the registration and the query is refused"
3702 : );
3703 : // no query selection at all: only the registration scope admits
3704 4 : let none = crate::registry::CsrSpec::default();
3705 4 : assert!(!admits_import(&r, &none, "urn:ngsi-ld:V:sk_zvolen:7"));
3706 : // exact query ids admit exactly themselves
3707 4 : let exact = crate::registry::CsrSpec {
3708 4 : ids: Some(vec!["urn:ngsi-ld:V:sk_zvolen:7".into()]),
3709 4 : ..Default::default()
3710 4 : };
3711 4 : assert!(admits_import(&r, &exact, "urn:ngsi-ld:V:sk_zvolen:7"));
3712 4 : assert!(!admits_import(&r, &exact, "urn:ngsi-ld:V:sk_zvolen:8"));
3713 4 : }
3714 :
3715 : /// 4.3.6.1/5.12: an idPattern-scoped registration gates payload items
3716 : /// exactly like an exact-id one — a foreign-razidlo item is not this
3717 : /// source's data; an id-less fragment cannot be disproven.
3718 : #[test]
3719 4 : fn covers_item_honours_entityinfo_id_patterns() {
3720 4 : let mut r = reg("redirect");
3721 4 : r.ent_patterns = vec!["^urn:ngsi-ld:V:sk_bb:.*$".into()];
3722 4 : let st = AppState::new("me".into());
3723 4 : let ctx = st.loader.core();
3724 12 : let item = |id: Option<&str>| {
3725 12 : let mut m = Map::new();
3726 12 : if let Some(id) = id {
3727 8 : m.insert("id".into(), Value::String(id.into()));
3728 8 : }
3729 12 : m
3730 12 : };
3731 4 : assert!(r.covers_item(&item(Some("urn:ngsi-ld:V:sk_bb:1")), &ctx));
3732 4 : assert!(
3733 4 : !r.covers_item(&item(Some("urn:ngsi-ld:V:sk_po:1")), &ctx),
3734 : "a foreign-razidlo item must not be covered"
3735 : );
3736 4 : assert!(
3737 4 : r.covers_item(&item(None), &ctx),
3738 : "id-less fragments stay covered"
3739 : );
3740 4 : }
3741 :
3742 : /// Table 5.2.40-1: "In the multi-tenancy use case (see clause 4.14), this
3743 : /// id shall be identifying a specific Tenant within a registered Context
3744 : /// Source." One alias for every tenant of a broker makes cross-tenant
3745 : /// federation inside that broker look like a loop.
3746 : #[test]
3747 4 : fn alias_identifies_the_tenant_not_just_the_broker() {
3748 20 : let tenant = |s: &str| antares_model::TenantId::new(s).expect("tenant");
3749 : // the default tenant keeps the bare alias — 6.3.14's own convention
3750 : // (the header is omitted, not sent as "default"), and the wire format
3751 : // every single-tenant peer already registered
3752 4 : assert_eq!(alias_for("antares1", &tenant("default")), "antares1");
3753 4 : assert_eq!(alias_for("antares1", &tenant("zvolen")), "antares1~zvolen");
3754 : // a chain naming this broker in ANOTHER tenant is not a loop: the
3755 : // registration points at a different (source, tenant) pair
3756 4 : let h = hdrs(Some("1.1 antares1~zvolen"));
3757 4 : assert!(via_loop(&h, &alias_for("antares1", &tenant("zvolen"))));
3758 4 : assert!(!via_loop(
3759 4 : &h,
3760 4 : &alias_for("antares1", &tenant("banskabystrica"))
3761 4 : ));
3762 4 : assert!(
3763 4 : !via_loop(&h, &alias_for("antares1", &tenant("default"))),
3764 : "the default tenant of this broker is its own Context Source"
3765 : );
3766 : // `~` cannot occur in a TenantId and is rejected in a configured
3767 : // alias, so the two halves can never blur into each other
3768 4 : assert!(antares_model::TenantId::new("a~b").is_err());
3769 4 : }
3770 :
3771 : /// Table 6.3.18-2: "the listing of previously encountered Context Sources
3772 : /// supplied is used when determining matching registrations", and 5.2.9
3773 : /// gives a registration the peer's `contextSourceAlias` "which is used to
3774 : /// identify loops". A source already in the chain is therefore not a
3775 : /// match — and the tenant-specific alias keeps that per (source, tenant).
3776 : #[tokio::test]
3777 4 : async fn registered_alias_in_the_via_chain_is_not_a_matching_registration() {
3778 4 : let st = AppState::new("me".into());
3779 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
3780 4 : let ctx = st.loader.core();
3781 12 : for (id, alias) in [
3782 4 : ("urn:ngsi-ld:ContextSourceRegistration:visited", "peer1"),
3783 4 : ("urn:ngsi-ld:ContextSourceRegistration:fresh", "peer2"),
3784 4 : ("urn:ngsi-ld:ContextSourceRegistration:anon", ""),
3785 4 : ] {
3786 12 : let mut doc = json!({
3787 12 : "id": id,
3788 12 : "type": "ContextSourceRegistration",
3789 12 : "endpoint": "http://peer:9090",
3790 12 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
3791 : });
3792 12 : if !alias.is_empty() {
3793 8 : doc["contextSourceAlias"] = json!(alias);
3794 8 : }
3795 12 : st.store
3796 12 : .create(&tenant, Kind::Registration, id, doc)
3797 12 : .await
3798 12 : .expect("seed registration");
3799 : }
3800 4 : let spec = crate::registry::CsrSpec {
3801 4 : types: Some(vec![
3802 4 : "https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()
3803 4 : ]),
3804 4 : ..Default::default()
3805 4 : };
3806 4 : let all = matching_regs(&st, &tenant, &spec, &ctx, &hdrs(None))
3807 4 : .await
3808 4 : .expect("registrations");
3809 4 : assert_eq!(all.len(), 3, "no Via ⇒ every registration matches");
3810 4 : let via = hdrs(Some("1.1 peer1"));
3811 4 : let left = matching_regs(&st, &tenant, &spec, &ctx, &via)
3812 4 : .await
3813 4 : .expect("registrations");
3814 8 : let ids: Vec<&str> = left.iter().map(|r| r.reg_id.as_str()).collect();
3815 4 : assert!(
3816 4 : !ids.contains(&"urn:ngsi-ld:ContextSourceRegistration:visited"),
3817 : "a source already in the Via chain must not match"
3818 : );
3819 4 : assert_eq!(
3820 4 : ids.len(),
3821 4 : 2,
3822 4 : "an unvisited peer and a registration with no alias still match"
3823 4 : );
3824 4 : }
3825 :
3826 : /// the registration mirror (hash order) and the store yield the
3827 : /// matching registrations in one order, by id, so a 207 names the same
3828 : /// registration whichever of the two answered.
3829 : #[tokio::test]
3830 4 : async fn matching_registrations_come_in_id_order_from_mirror_and_store() {
3831 4 : let mut st = AppState::new("me".into());
3832 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
3833 4 : let ctx = st.loader.core();
3834 4 : let mirror = crate::mirror::DocMirror::default();
3835 48 : for n in (0..12).rev() {
3836 48 : let id = format!("urn:ngsi-ld:ContextSourceRegistration:r{n:02}");
3837 48 : let doc = json!({
3838 48 : "id": id,
3839 48 : "type": "ContextSourceRegistration",
3840 48 : "endpoint": format!("http://peer{n}:9090"),
3841 48 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
3842 : });
3843 48 : mirror.apply("default", &id, Some(doc.clone()));
3844 48 : st.store
3845 48 : .create(&tenant, Kind::Registration, &id, doc)
3846 48 : .await
3847 48 : .expect("seed registration");
3848 : }
3849 4 : let spec = crate::registry::CsrSpec::default();
3850 4 : let want: Vec<String> = (0..12)
3851 48 : .map(|n| format!("urn:ngsi-ld:ContextSourceRegistration:r{n:02}"))
3852 4 : .collect();
3853 4 : let from_store: Vec<String> = matching_regs(&st, &tenant, &spec, &ctx, &hdrs(None))
3854 4 : .await
3855 4 : .expect("store")
3856 4 : .into_iter()
3857 4 : .map(|r| r.reg_id)
3858 4 : .collect();
3859 4 : assert_eq!(from_store, want, "the store answers by id");
3860 4 : st.reg_mirror = Some(std::sync::Arc::new(mirror));
3861 4 : let from_mirror: Vec<String> = matching_regs(&st, &tenant, &spec, &ctx, &hdrs(None))
3862 4 : .await
3863 4 : .expect("mirror")
3864 4 : .into_iter()
3865 4 : .map(|r| r.reg_id)
3866 4 : .collect();
3867 4 : assert_eq!(from_mirror, want, "the mirror answers in the store's order");
3868 4 : }
3869 :
3870 : /// TS 104 175 9.7: a write whose Via names this broker is a
3871 : /// detected loop whatever its registrations; nothing is forwarded.
3872 : #[test]
3873 4 : fn a_write_looping_back_is_508_whatever_its_registrations() {
3874 4 : let t = antares_model::TenantId::new("default").expect("tenant");
3875 4 : let h = hdrs(Some("1.1 me"));
3876 20 : for set in [
3877 4 : vec![reg("exclusive")],
3878 4 : vec![reg("redirect")],
3879 4 : vec![reg("inclusive")],
3880 4 : vec![reg("exclusive"), reg("inclusive")],
3881 4 : Vec::new(),
3882 4 : ] {
3883 20 : let mut regs = set;
3884 20 : let resp = handle_via_loop(&h, "me", &t, &mut regs).expect("a loop");
3885 20 : assert_eq!(resp.status(), StatusCode::LOOP_DETECTED);
3886 20 : assert!(regs.is_empty(), "looping forwards must be dropped");
3887 : }
3888 : // no loop → untouched
3889 4 : let mut regs = vec![reg("exclusive")];
3890 4 : assert!(handle_via_loop(&hdrs(None), "me", &t, &mut regs).is_none());
3891 4 : assert_eq!(regs.len(), 1);
3892 4 : }
3893 :
3894 : /// 4.3.6.4 / 5.2.9 localOnly: "distributed operations associated to this
3895 : /// Context Source Registration will act only on data held directly by
3896 : /// the registered Context Source itself" — the flag must survive
3897 : /// registration compilation so every forward can carry local=true.
3898 : #[tokio::test]
3899 4 : async fn local_only_survives_registration_compilation() {
3900 4 : let st = AppState::new("me".into());
3901 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
3902 4 : let ctx = st.loader.core();
3903 8 : for (id, local_only) in [
3904 4 : ("urn:ngsi-ld:ContextSourceRegistration:lo", true),
3905 4 : ("urn:ngsi-ld:ContextSourceRegistration:casc", false),
3906 4 : ] {
3907 8 : let mut doc = json!({
3908 8 : "id": id,
3909 8 : "type": "ContextSourceRegistration",
3910 8 : "endpoint": "http://peer:9090",
3911 8 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
3912 : });
3913 8 : if local_only {
3914 4 : doc["localOnly"] = json!(true);
3915 4 : }
3916 8 : st.store
3917 8 : .create(&tenant, Kind::Registration, id, doc)
3918 8 : .await
3919 8 : .expect("seed registration");
3920 : }
3921 4 : let spec = crate::registry::CsrSpec {
3922 4 : types: Some(vec![
3923 4 : "https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()
3924 4 : ]),
3925 4 : ..Default::default()
3926 4 : };
3927 4 : let regs = matching_regs(&st, &tenant, &spec, &ctx, &HeaderMap::new())
3928 4 : .await
3929 4 : .expect("registrations");
3930 8 : let lo = |id: &str| {
3931 8 : regs.iter()
3932 12 : .find(|r| r.reg_id == id)
3933 8 : .expect("compiled")
3934 : .local_only
3935 8 : };
3936 4 : assert!(lo("urn:ngsi-ld:ContextSourceRegistration:lo"));
3937 4 : assert!(!lo("urn:ngsi-ld:ContextSourceRegistration:casc"));
3938 4 : }
3939 :
3940 : /// Table 5.2.34-1: management.localOnly — "distributed operations
3941 : /// associated to this Context Source Registration will act only on data
3942 : /// held directly by the registered Context Source itself".
3943 : #[tokio::test]
3944 4 : async fn management_local_only_survives_registration_compilation() {
3945 4 : let st = AppState::new("me".into());
3946 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
3947 4 : let ctx = st.loader.core();
3948 4 : let id = "urn:ngsi-ld:ContextSourceRegistration:mgmt-lo";
3949 4 : let doc = json!({
3950 4 : "id": id,
3951 4 : "type": "ContextSourceRegistration",
3952 4 : "endpoint": "http://peer:9090",
3953 4 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
3954 4 : "management": {"localOnly": true}
3955 : });
3956 4 : st.store
3957 4 : .create(&tenant, Kind::Registration, id, doc)
3958 4 : .await
3959 4 : .expect("seed registration");
3960 4 : let spec = crate::registry::CsrSpec {
3961 4 : types: Some(vec![
3962 4 : "https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()
3963 4 : ]),
3964 4 : ..Default::default()
3965 4 : };
3966 4 : let regs = matching_regs(&st, &tenant, &spec, &ctx, &HeaderMap::new())
3967 4 : .await
3968 4 : .expect("registrations");
3969 4 : assert!(
3970 4 : regs.iter()
3971 4 : .find(|r| r.reg_id == id)
3972 4 : .expect("compiled")
3973 4 : .local_only,
3974 4 : "management.localOnly must compile into the forward flag"
3975 4 : );
3976 4 : }
3977 :
3978 : /// 4.3.6.2: "An auxiliary Context Source Registration never overrides
3979 : /// data held directly within a Context Broker" — supplementary attributes
3980 : /// are included, conflicting ones lose to the base regardless of recency.
3981 : #[test]
3982 4 : fn auxiliary_merge_supplements_but_never_overrides() {
3983 4 : let attr = "https://uri.etsi.org/ngsi-ld/default-context/speed";
3984 4 : let extra = "https://uri.etsi.org/ngsi-ld/default-context/color";
3985 4 : let mut base = json!({
3986 4 : "id": "urn:x", "type": ["T"],
3987 4 : attr: [{"type": "Property", "value": 1, "modifiedAt": "2020-01-01T00:00:00Z"}]
3988 : });
3989 : // aux instance is FRESHER and would win a 4.5.5.3 recency merge —
3990 : // auxiliary mode must still lose the conflict, yet supplement `color`
3991 4 : let add = json!({
3992 4 : "id": "urn:x", "type": ["T"],
3993 4 : attr: [{"type": "Property", "value": 2, "modifiedAt": "2026-01-01T00:00:00Z"}],
3994 4 : extra: [{"type": "Property", "value": "red"}]
3995 : });
3996 4 : merge_docs(&mut base, &add, true);
3997 4 : assert_eq!(base[attr][0]["value"], 1, "aux must not override local");
3998 4 : assert_eq!(base[extra][0]["value"], "red", "aux supplement is included");
3999 : // the same add as a non-aux inclusive source DOES win on recency
4000 4 : let mut base2 = json!({
4001 4 : "id": "urn:x", "type": ["T"],
4002 4 : attr: [{"type": "Property", "value": 1, "modifiedAt": "2020-01-01T00:00:00Z"}]
4003 : });
4004 4 : merge_docs(&mut base2, &add, false);
4005 4 : assert_eq!(base2[attr][0]["value"], 2);
4006 4 : }
4007 :
4008 : /// 4.20: retrieveEntity implements only 5.7.1 — a source offering it
4009 : /// alone is never a query target; queryEntity/queryBatch are.
4010 : #[test]
4011 4 : fn query_op_requires_query_support() {
4012 4 : let reg =
4013 20 : |ops: &[&str]| fed_reg_of("urn:r", &json!({"endpoint": "http://x", "operations": ops}));
4014 4 : assert_eq!(reg(&["retrieveEntity"]).query_op(), None);
4015 4 : assert_eq!(reg(&["queryEntity"]).query_op(), Some("queryEntity"));
4016 4 : assert_eq!(reg(&["queryBatch"]).query_op(), Some("queryBatch"));
4017 4 : assert_eq!(reg(&["federationOps"]).query_op(), Some("queryEntity"));
4018 4 : assert_eq!(reg(&["retrieveOps"]).query_op(), Some("queryEntity"));
4019 4 : }
4020 :
4021 12 : async fn seed_reg(
4022 12 : st: &AppState,
4023 12 : tenant: &antares_model::TenantId,
4024 12 : id: &str,
4025 12 : mode: &str,
4026 12 : entities: Value,
4027 12 : ) {
4028 12 : let doc = json!({
4029 12 : "id": id,
4030 12 : "type": "ContextSourceRegistration",
4031 12 : "mode": mode,
4032 12 : "operations": ["redirectionOps"],
4033 12 : "endpoint": "http://peer:9090",
4034 12 : "information": [{"entities": entities}],
4035 : });
4036 12 : st.store
4037 12 : .create(tenant, Kind::Registration, id, doc)
4038 12 : .await
4039 12 : .expect("seed registration");
4040 12 : }
4041 :
4042 : /// 5.2.9 / 5.12: an `idPattern` in the registration's EntityInfo matches
4043 : /// a write addressed by id, so the write forwards to that source; a
4044 : /// pattern the id does not match keeps the write local.
4045 : #[tokio::test]
4046 4 : async fn write_plan_forwards_a_write_by_id_to_an_id_pattern_registration() {
4047 4 : let st = AppState::new("me".into());
4048 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
4049 4 : let ctx = st.loader.core();
4050 4 : seed_reg(
4051 4 : &st,
4052 4 : &tenant,
4053 4 : "urn:ngsi-ld:ContextSourceRegistration:cars",
4054 4 : "exclusive",
4055 4 : json!([{"idPattern": "urn:ngsi-ld:Vehicle:.*"}]),
4056 4 : )
4057 4 : .await;
4058 4 : seed_reg(
4059 4 : &st,
4060 4 : &tenant,
4061 4 : "urn:ngsi-ld:ContextSourceRegistration:bikes",
4062 4 : "exclusive",
4063 4 : json!([{"idPattern": "urn:ngsi-ld:Bike:.*"}]),
4064 4 : )
4065 4 : .await;
4066 4 : let spec = crate::registry::CsrSpec {
4067 4 : ids: Some(vec!["urn:ngsi-ld:Vehicle:1".into()]),
4068 4 : ..Default::default()
4069 4 : };
4070 4 : let plan = write_plan(
4071 4 : &st,
4072 4 : &tenant,
4073 4 : &spec,
4074 4 : &ctx,
4075 4 : &HashMap::new(),
4076 4 : &HeaderMap::new(),
4077 4 : )
4078 4 : .await
4079 4 : .expect("plan");
4080 4 : let WritePlan::Forward(regs) = plan else {
4081 0 : panic!("no Via chain, so nothing answers early");
4082 : };
4083 4 : let ids: Vec<&str> = regs.iter().map(|r| r.reg_id.as_str()).collect();
4084 4 : assert_eq!(ids, vec!["urn:ngsi-ld:ContextSourceRegistration:cars"]);
4085 4 : }
4086 :
4087 : /// 6.3.18: a `Via` chain that already names this broker is answered by
4088 : /// the chain rule before any registration is used — with a single
4089 : /// exclusive source that is the 508 of Table 6.3.18-2 — and a chain
4090 : /// past the hop cap is 508 regardless of what matched.
4091 : #[tokio::test]
4092 4 : async fn write_plan_answers_a_via_loop_before_forwarding() {
4093 4 : let st = AppState::new("me".into());
4094 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
4095 4 : let ctx = st.loader.core();
4096 4 : seed_reg(
4097 4 : &st,
4098 4 : &tenant,
4099 4 : "urn:ngsi-ld:ContextSourceRegistration:cars",
4100 4 : "exclusive",
4101 4 : json!([{"idPattern": "urn:ngsi-ld:Vehicle:.*"}]),
4102 4 : )
4103 4 : .await;
4104 4 : let spec = crate::registry::CsrSpec {
4105 4 : ids: Some(vec!["urn:ngsi-ld:Vehicle:1".into()]),
4106 4 : ..Default::default()
4107 4 : };
4108 4 : let looped = write_plan(
4109 4 : &st,
4110 4 : &tenant,
4111 4 : &spec,
4112 4 : &ctx,
4113 4 : &HashMap::new(),
4114 4 : &hdrs(Some("1.1 me")),
4115 4 : )
4116 4 : .await
4117 4 : .expect("plan");
4118 4 : let WritePlan::Answered(resp) = looped else {
4119 0 : panic!("a Via chain naming this broker with one exclusive source is answered, not forwarded");
4120 : };
4121 4 : assert_eq!(resp.status(), 508);
4122 4 : let deep: String = (0..=MAX_VIA_HOPS)
4123 132 : .map(|i| format!("1.1 hop{i}"))
4124 4 : .collect::<Vec<_>>()
4125 4 : .join(", ");
4126 4 : let WritePlan::Answered(resp) = write_plan(
4127 4 : &st,
4128 4 : &tenant,
4129 4 : &spec,
4130 4 : &ctx,
4131 4 : &HashMap::new(),
4132 4 : &hdrs(Some(&deep)),
4133 4 : )
4134 4 : .await
4135 4 : .expect("plan") else {
4136 0 : panic!("a chain past the hop cap is refused outright");
4137 : };
4138 4 : assert_eq!(resp.status(), 508);
4139 : // no chain, no early answer: the same registration forwards
4140 4 : assert!(matches!(
4141 4 : write_plan(&st, &tenant, &spec, &ctx, &HashMap::new(), &HeaderMap::new()).await.expect("plan"),
4142 4 : WritePlan::Forward(regs) if regs.len() == 1
4143 4 : ));
4144 4 : }
4145 :
4146 : /// 4.3.6.2: "Auxiliary distributed operations are limited to context
4147 : /// information consumption operations" — write_regs must drop a matching
4148 : /// auxiliary registration while keeping an inclusive one.
4149 : #[tokio::test]
4150 4 : async fn write_regs_exclude_auxiliary_registrations() {
4151 4 : let st = AppState::new("me".into());
4152 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
4153 4 : let ctx = st.loader.core();
4154 8 : for (id, mode) in [
4155 4 : ("urn:ngsi-ld:ContextSourceRegistration:aux", "auxiliary"),
4156 4 : ("urn:ngsi-ld:ContextSourceRegistration:inc", "inclusive"),
4157 4 : ] {
4158 8 : let doc = json!({
4159 8 : "id": id,
4160 8 : "type": "ContextSourceRegistration",
4161 8 : "mode": mode,
4162 8 : "operations": ["redirectionOps"],
4163 8 : "endpoint": "http://peer:9090",
4164 8 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
4165 : });
4166 8 : st.store
4167 8 : .create(&tenant, Kind::Registration, id, doc)
4168 8 : .await
4169 8 : .expect("seed registration");
4170 : }
4171 4 : let spec = crate::registry::CsrSpec {
4172 4 : types: Some(vec![
4173 4 : "https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()
4174 4 : ]),
4175 4 : ..Default::default()
4176 4 : };
4177 4 : let regs = write_regs(
4178 4 : &st,
4179 4 : &tenant,
4180 4 : &spec,
4181 4 : &ctx,
4182 4 : &HashMap::new(),
4183 4 : &HeaderMap::new(),
4184 4 : )
4185 4 : .await
4186 4 : .expect("registrations");
4187 4 : let ids: Vec<&str> = regs.iter().map(|r| r.reg_id.as_str()).collect();
4188 4 : assert_eq!(ids, vec!["urn:ngsi-ld:ContextSourceRegistration:inc"]);
4189 4 : }
4190 :
4191 : /// 4.3.6.1: "Context Brokers shall respect" a Context Source's declared
4192 : /// operations subset — explicit operation names and operation groups
4193 : /// (5.2.9) both gate; anything outside the list must not be forwarded.
4194 : #[test]
4195 4 : fn operations_subset_gates_forwarding() {
4196 4 : let mut r = reg("inclusive"); // ops = ["federationOps"], the 5.2.9 default
4197 4 : assert!(r.supports("queryEntity"));
4198 4 : assert!(r.supports("createSubscription"));
4199 4 : assert!(
4200 4 : !r.supports("createEntity"),
4201 : "federationOps carries no provision operations"
4202 : );
4203 4 : r.ops = vec!["updateOps".into()];
4204 4 : assert!(r.supports("updateAttrs"));
4205 4 : assert!(!r.supports("queryEntity"));
4206 4 : r.ops = vec!["createEntity".into()];
4207 4 : assert!(r.supports("createEntity"), "explicit op name matches");
4208 4 : assert!(!r.supports("deleteEntity"));
4209 4 : r.ops = vec![];
4210 4 : assert!(!r.supports("queryEntity"), "empty subset forwards nothing");
4211 4 : }
4212 :
4213 : /// 4.3.6.1: "all constraints specified in the registration shall be
4214 : /// respected" — a forwarded fragment is reduced to the attributes the
4215 : /// RegistrationInfo covers; when nothing covered remains there is no
4216 : /// forward at all (None).
4217 : #[test]
4218 4 : fn forwarded_fragment_reduced_to_registration_scope() {
4219 4 : let st = AppState::new("me".into());
4220 4 : let ctx = st.loader.core();
4221 4 : let speed = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4222 4 : let mut r = reg("inclusive");
4223 4 : r.attrs = Some(vec![speed.into()]);
4224 4 : let obj = json!({
4225 4 : "id": "urn:x", "type": "Vehicle",
4226 4 : "speed": {"type": "Property", "value": 3},
4227 4 : "color": {"type": "Property", "value": "red"},
4228 4 : "@context": "https://example.org/ctx.jsonld"
4229 : });
4230 4 : let out = reduce_to_scope(obj.as_object().expect("obj"), &r, &ctx).expect("covered");
4231 4 : let out = out.as_object().expect("out");
4232 4 : assert!(out.contains_key("speed"));
4233 4 : assert!(out.contains_key("id") && out.contains_key("type"));
4234 4 : assert!(
4235 4 : !out.contains_key("color"),
4236 : "uncovered attribute must be dropped"
4237 : );
4238 4 : assert!(!out.contains_key("@context"));
4239 : // nothing covered ⇒ no forwarded fragment
4240 4 : let only_color = json!({
4241 4 : "id": "urn:x", "type": "Vehicle",
4242 4 : "color": {"type": "Property", "value": "red"}
4243 : });
4244 4 : assert!(reduce_to_scope(only_color.as_object().expect("obj"), &r, &ctx).is_none());
4245 : // an unscoped registration (attrs: None) passes everything but @context
4246 4 : r.attrs = None;
4247 4 : let full = reduce_to_scope(obj.as_object().expect("obj"), &r, &ctx).expect("all");
4248 4 : let full = full.as_object().expect("full");
4249 4 : assert!(full.contains_key("color") && !full.contains_key("@context"));
4250 4 : }
4251 :
4252 : /// 4.5.5.3 p.60: "if an expiresAt DateTime is present on the
4253 : /// Attribute and the date lies in the past, it shall be discarded" —
4254 : /// BEFORE the observedAt/modifiedAt recency comparison.
4255 : #[test]
4256 4 : fn merge_discards_expired_instances_before_recency() {
4257 4 : let attr = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4258 4 : let mut base = json!({
4259 4 : "id": "urn:x", "type": ["T"],
4260 4 : attr: [{"type": "Property", "value": 1, "modifiedAt": "2026-01-01T00:00:00Z"}]
4261 : });
4262 : // fresher instance, but expired → must NOT win
4263 4 : let add = json!({
4264 4 : "id": "urn:x", "type": ["T"],
4265 4 : attr: [{"type": "Property", "value": 2,
4266 4 : "modifiedAt": "2026-06-01T00:00:00Z",
4267 4 : "expiresAt": "2020-01-01T00:00:00Z"}]
4268 : });
4269 4 : merge_docs(&mut base, &add, false);
4270 4 : assert_eq!(base[attr][0]["value"], 1, "expired instance was discarded");
4271 : // an expired BASE instance loses to a live remote one even if fresher
4272 4 : let mut base2 = json!({
4273 4 : "id": "urn:x", "type": ["T"],
4274 4 : attr: [{"type": "Property", "value": 1,
4275 4 : "modifiedAt": "2026-06-01T00:00:00Z",
4276 4 : "expiresAt": "2020-01-01T00:00:00Z"}]
4277 : });
4278 4 : let add2 = json!({
4279 4 : "id": "urn:x", "type": ["T"],
4280 4 : attr: [{"type": "Property", "value": 2, "modifiedAt": "2026-01-01T00:00:00Z"}]
4281 : });
4282 4 : merge_docs(&mut base2, &add2, false);
4283 4 : assert_eq!(base2[attr][0]["value"], 2, "live instance replaces expired");
4284 4 : }
4285 :
4286 : /// TS 104 175 8.5.3: "any attribute data where an observedAt DateTime is
4287 : /// present shall take precedence ... If no observedAt is present, the
4288 : /// attribute data with the most recent modifiedAt DateTime shall be
4289 : /// provided": an old observation beats a newer write that observed
4290 : /// nothing, whichever source answered first.
4291 : #[test]
4292 4 : fn merge_prefers_an_observed_instance_over_a_newer_unobserved_one() {
4293 4 : let attr = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4294 4 : let observed = json!({
4295 4 : "id": "urn:x", "type": ["T"],
4296 4 : attr: [{"type": "Property", "value": "observed",
4297 4 : "observedAt": "2020-01-01T00:00:00Z",
4298 4 : "modifiedAt": "2020-01-01T00:00:00Z"}]
4299 : });
4300 4 : let unobserved = json!({
4301 4 : "id": "urn:x", "type": ["T"],
4302 4 : attr: [{"type": "Property", "value": "unobserved",
4303 4 : "modifiedAt": "2026-01-01T00:00:00Z"}]
4304 : });
4305 8 : for (first, second) in [(&observed, &unobserved), (&unobserved, &observed)] {
4306 8 : let mut base = first.clone();
4307 8 : merge_docs(&mut base, second, false);
4308 8 : assert_eq!(base[attr][0]["value"], "observed", "{first} then {second}");
4309 : }
4310 : // between two observations the later one wins, between two
4311 : // unobserved instances the later write
4312 4 : let mut later = observed.clone();
4313 4 : later[attr][0]["observedAt"] = json!("2021-01-01T00:00:00Z");
4314 4 : later[attr][0]["value"] = json!("later");
4315 4 : let mut base = observed.clone();
4316 4 : merge_docs(&mut base, &later, false);
4317 4 : assert_eq!(base[attr][0]["value"], "later");
4318 4 : let mut newer = unobserved.clone();
4319 4 : newer[attr][0]["modifiedAt"] = json!("2027-01-01T00:00:00Z");
4320 4 : newer[attr][0]["value"] = json!("newer");
4321 4 : let mut base = unobserved.clone();
4322 4 : merge_docs(&mut base, &newer, false);
4323 4 : assert_eq!(base[attr][0]["value"], "newer");
4324 4 : }
4325 :
4326 : /// 4.5.5.3: entity-level expiresAt — missing from at least one received
4327 : /// version → removed; present in all versions → furthest in the future.
4328 : #[test]
4329 4 : fn merge_entity_expires_at_intersection_and_max() {
4330 4 : let mut base = json!({"id": "urn:x", "type": ["T"], "expiresAt": "2030-01-01T00:00:00Z"});
4331 4 : let add = json!({"id": "urn:x", "type": ["T"], "expiresAt": "2031-01-01T00:00:00Z"});
4332 4 : merge_docs(&mut base, &add, false);
4333 4 : assert_eq!(base["expiresAt"], "2031-01-01T00:00:00Z");
4334 : // one version without expiresAt → removed
4335 4 : let add2 = json!({"id": "urn:x", "type": ["T"]});
4336 4 : merge_docs(&mut base, &add2, false);
4337 4 : assert!(base.get("expiresAt").is_none(), "expiresAt must be removed");
4338 : // and never re-introduced by a later version that has one
4339 4 : let add3 = json!({"id": "urn:x", "type": ["T"], "expiresAt": "2032-01-01T00:00:00Z"});
4340 4 : merge_docs(&mut base, &add3, false);
4341 4 : assert!(base.get("expiresAt").is_none());
4342 4 : }
4343 :
4344 : /// 4.3.6.2: "An auxiliary Context Source Registration never overrides
4345 : /// data held directly within a Context Broker." The entity-level
4346 : /// expiresAt reconciliation is part of that data, so an auxiliary
4347 : /// version can neither remove it nor push it further out.
4348 : #[test]
4349 4 : fn auxiliary_merge_never_touches_entity_expires_at() {
4350 4 : let mut base = json!({"id": "urn:x", "type": ["T"], "expiresAt": "2030-01-01T00:00:00Z"});
4351 4 : let aux_without = json!({"id": "urn:x", "type": ["T"]});
4352 4 : merge_docs(&mut base, &aux_without, true);
4353 4 : assert_eq!(
4354 4 : base["expiresAt"], "2030-01-01T00:00:00Z",
4355 : "an auxiliary version lacking expiresAt must not remove the broker's own"
4356 : );
4357 4 : let aux_later = json!({"id": "urn:x", "type": ["T"], "expiresAt": "2031-01-01T00:00:00Z"});
4358 4 : merge_docs(&mut base, &aux_later, true);
4359 4 : assert_eq!(
4360 4 : base["expiresAt"], "2030-01-01T00:00:00Z",
4361 : "an auxiliary version must not extend the broker's own expiresAt"
4362 : );
4363 4 : }
4364 :
4365 : /// 4.5.5.3 arbitrates on the most recent DateTime, and 4.6.3 lets the
4366 : /// same instant be written with or without a fraction — so the winner
4367 : /// must be chosen on the instant, never on the spelling.
4368 : #[test]
4369 4 : fn recency_arbitrates_on_the_instant_not_the_spelling() {
4370 4 : let attr = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4371 4 : let mut base = json!({
4372 4 : "id": "urn:x", "type": ["T"],
4373 4 : attr: [{"type": "Property", "value": 1, "observedAt": "2026-01-01T00:00:01Z"}]
4374 : });
4375 4 : let add = json!({
4376 4 : "id": "urn:x", "type": ["T"],
4377 4 : attr: [{"type": "Property", "value": 2, "observedAt": "2026-01-01T00:00:01.500Z"}]
4378 : });
4379 4 : merge_docs(&mut base, &add, false);
4380 4 : assert_eq!(
4381 4 : base[attr][0]["value"], 2,
4382 : "the later instant wins even though its string sorts lower"
4383 : );
4384 : // and the converse: a fraction that is EARLIER must not win
4385 4 : let older = json!({
4386 4 : "id": "urn:x", "type": ["T"],
4387 4 : attr: [{"type": "Property", "value": 3, "observedAt": "2026-01-01T00:00:01.250Z"}]
4388 : });
4389 4 : merge_docs(&mut base, &older, false);
4390 4 : assert_eq!(base[attr][0]["value"], 2, "an earlier instant must not win");
4391 4 : }
4392 :
4393 : /// 4.5.5.2/4.5.7: the registration-scope filter narrows ATTRIBUTES, so
4394 : /// the entity-level lifetime members must cross it — dropping expiresAt
4395 : /// here made the 4.5.5.3 reconciliation delete the local one.
4396 : #[test]
4397 4 : fn scoped_import_keeps_the_entity_level_lifetime_members() {
4398 4 : let speed = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4399 4 : let reg = FedReg {
4400 4 : attrs: Some(vec![speed.to_owned()]),
4401 4 : ..FedReg::default()
4402 4 : };
4403 4 : let remote = json!({
4404 4 : "id": "urn:ngsi-ld:Vehicle:1",
4405 4 : "type": "Vehicle",
4406 4 : "expiresAt": "2030-01-01T00:00:00Z",
4407 4 : "speed": {"type": "Property", "value": 1},
4408 4 : "brandName": {"type": "Property", "value": "x"}
4409 : });
4410 4 : let ctx = antares_jsonld::Loader::new().core();
4411 4 : let imported = import_entity(&remote, ®, &ctx).expect("import");
4412 4 : assert_eq!(
4413 4 : imported["expiresAt"], "2030-01-01T00:00:00Z",
4414 : "entity-level expiresAt must survive the scope filter"
4415 : );
4416 4 : assert!(
4417 4 : imported
4418 4 : .get("https://uri.etsi.org/ngsi-ld/default-context/brandName")
4419 4 : .is_none(),
4420 : "an out-of-scope attribute must not be imported"
4421 : );
4422 4 : }
4423 :
4424 : /// 4.5.5.2: a received version's entity-level expiresAt is pushed onto
4425 : /// each Attribute instance — added where absent, capped where the
4426 : /// Attribute's own expiresAt lies further in the future.
4427 : #[test]
4428 4 : fn merge_pushes_entity_expires_at_onto_attributes() {
4429 4 : let attr = "https://uri.etsi.org/ngsi-ld/default-context/speed";
4430 4 : let mut base = json!({"id": "urn:x", "type": ["T"]});
4431 4 : let add = json!({
4432 4 : "id": "urn:x", "type": ["T"],
4433 4 : "expiresAt": "2030-01-01T00:00:00Z",
4434 4 : attr: [
4435 4 : {"type": "Property", "value": 1},
4436 4 : {"type": "Property", "value": 2, "datasetId": "urn:ngsi-ld:Dataset:1",
4437 4 : "expiresAt": "2035-01-01T00:00:00Z"},
4438 4 : {"type": "Property", "value": 3, "datasetId": "urn:ngsi-ld:Dataset:2",
4439 4 : "expiresAt": "2029-01-01T00:00:00Z"}
4440 : ]
4441 : });
4442 4 : merge_docs(&mut base, &add, false);
4443 4 : let inst = base[attr].as_array().expect("attr array");
4444 4 : assert_eq!(inst[0]["expiresAt"], "2030-01-01T00:00:00Z", "added");
4445 4 : assert_eq!(inst[1]["expiresAt"], "2030-01-01T00:00:00Z", "capped");
4446 4 : assert_eq!(inst[2]["expiresAt"], "2029-01-01T00:00:00Z", "earlier kept");
4447 4 : }
4448 :
4449 : /// 6.3.17: "the error response should be as informative as possible" —
4450 : /// informative about the OPERATION, not about the deployment. The part
4451 : /// that failed is identified by its Context Source Registration id
4452 : /// (5.2.9), which the client can retrieve from /csourceRegistrations;
4453 : /// the registration `endpoint` is not part of any client-facing payload,
4454 : /// and a client able to provoke a partial failure must not be able to
4455 : /// enumerate the address of every registered Context Source.
4456 : #[tokio::test]
4457 4 : async fn partial_failure_detail_omits_the_peer_endpoint() {
4458 : use std::io::{Read, Write};
4459 4 : crate::allow_private();
4460 : // a Context Source that refuses every forwarded write
4461 4 : let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
4462 4 : let port = listener.local_addr().expect("addr").port();
4463 4 : std::thread::spawn(move || {
4464 4 : for stream in listener.incoming() {
4465 4 : let Ok(mut s) = stream else { continue };
4466 4 : let mut buf = [0u8; 4096];
4467 4 : let _ = s.read(&mut buf);
4468 4 : let _ = s.write_all(
4469 4 : b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
4470 4 : );
4471 : }
4472 4 : });
4473 4 : let st = AppState::new("me".into());
4474 4 : let tenant = antares_model::TenantId::new("default").expect("tenant");
4475 4 : let mut r = reg("inclusive");
4476 4 : r.endpoint = format!("http://127.0.0.1:{port}");
4477 4 : let part = forward_part(
4478 4 : &st,
4479 4 : reqwest::Method::POST,
4480 4 : format!("{}/ngsi-ld/v1/entities", r.endpoint),
4481 4 : &[],
4482 4 : &HeaderMap::new(),
4483 4 : &tenant,
4484 4 : &r,
4485 4 : antares_jsonld::CORE_CONTEXT,
4486 4 : Some(json!({"id": "urn:ngsi-ld:V:1", "type": "Vehicle"})),
4487 4 : )
4488 4 : .await;
4489 4 : assert_eq!(part.status, 400, "the peer refused the write");
4490 : // one failed forward + one succeeded local part ⇒ 207 Multi-Status
4491 4 : let local = Part::local(204, "local write applied");
4492 4 : let resp = combine(
4493 4 : crate::Profile::default(),
4494 4 : Some("urn:ngsi-ld:V:1"),
4495 4 : vec![local, part],
4496 4 : StatusCode::NO_CONTENT.into_response(),
4497 4 : &tenant,
4498 : );
4499 4 : assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
4500 4 : let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
4501 4 : .await
4502 4 : .expect("body");
4503 4 : let body = String::from_utf8_lossy(&bytes).into_owned();
4504 4 : assert!(
4505 4 : !body.contains(&format!("127.0.0.1:{port}")),
4506 : "the peer host:port must never reach the client, got {body}"
4507 : );
4508 4 : assert!(
4509 4 : body.contains(&r.reg_id),
4510 4 : "the registration id is the client-safe identifier, got {body}"
4511 4 : );
4512 4 : }
4513 :
4514 : /// 6.3.18: the Via header exists "to avoid infinite loops", and Table
4515 : /// 6.3.18-2 makes its listing part of registration matching — every
4516 : /// element is compared against every candidate registration. A chain
4517 : /// longer than any real cascade is therefore both a loop symptom and a
4518 : /// work amplifier, and is refused before the registrations are read.
4519 : #[tokio::test]
4520 4 : async fn via_chain_beyond_the_hop_ceiling_is_refused() {
4521 8 : let chain = |n: usize| {
4522 8 : (0..n)
4523 260 : .map(|i| format!("1.1 hop{i}"))
4524 8 : .collect::<Vec<_>>()
4525 8 : .join(", ")
4526 8 : };
4527 4 : let t = antares_model::TenantId::new("default").expect("tenant");
4528 4 : let over = hdrs(Some(&chain(MAX_VIA_HOPS + 1)));
4529 4 : assert!(
4530 4 : via_loop(&over, "me"),
4531 : "a chain past the ceiling is treated as a loop"
4532 : );
4533 4 : let mut regs = vec![reg("inclusive")];
4534 4 : let resp = handle_via_loop(&over, "me", &t, &mut regs)
4535 4 : .expect("an over-long Via chain must be refused");
4536 4 : assert_eq!(resp.status(), StatusCode::LOOP_DETECTED);
4537 : // and no registration is consulted: the candidate set is empty
4538 : // without a single registration document being examined
4539 4 : let st = AppState::new("me".into());
4540 4 : let ctx = st.loader.core();
4541 4 : let id = "urn:ngsi-ld:ContextSourceRegistration:hops";
4542 4 : st.store
4543 4 : .create(
4544 4 : &t,
4545 4 : Kind::Registration,
4546 4 : id,
4547 4 : json!({
4548 4 : "id": id,
4549 4 : "type": "ContextSourceRegistration",
4550 4 : "endpoint": "http://peer:9090",
4551 4 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
4552 4 : }),
4553 4 : ).await
4554 4 : .expect("seed registration");
4555 4 : let spec = crate::registry::CsrSpec {
4556 4 : types: Some(vec![
4557 4 : "https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()
4558 4 : ]),
4559 4 : ..Default::default()
4560 4 : };
4561 4 : assert!(
4562 4 : matching_regs(&st, &t, &spec, &ctx, &over)
4563 4 : .await
4564 4 : .expect("registrations")
4565 4 : .is_empty(),
4566 : "no registration is matched past the hop ceiling"
4567 : );
4568 : // at the ceiling the chain is still processed normally
4569 4 : let at = hdrs(Some(&chain(MAX_VIA_HOPS)));
4570 4 : assert!(!via_loop(&at, "me"));
4571 4 : assert!(handle_via_loop(&at, "me", &t, &mut vec![reg("inclusive")]).is_none());
4572 4 : assert_eq!(
4573 4 : matching_regs(&st, &t, &spec, &ctx, &at)
4574 4 : .await
4575 4 : .expect("registrations")
4576 4 : .len(),
4577 4 : 1
4578 4 : );
4579 4 : }
4580 :
4581 : /// 5.7.2.4/4.23.1: `would_federate` only answers WHETHER a query leaves
4582 : /// the local scope, so it never compiles a forwarding set — but its
4583 : /// verdict must stay identical to the set's emptiness for every shape
4584 : /// that gates matching (type, id, the Via chain, local scope).
4585 : #[tokio::test]
4586 4 : async fn would_federate_agrees_with_the_compiled_forward_set() {
4587 4 : let st = AppState::new("me".into());
4588 4 : let t = antares_model::TenantId::new("default").expect("tenant");
4589 4 : let ctx = st.loader.core();
4590 4 : let id = "urn:ngsi-ld:ContextSourceRegistration:wf";
4591 4 : st.store
4592 4 : .create(
4593 4 : &t,
4594 4 : Kind::Registration,
4595 4 : id,
4596 4 : json!({
4597 4 : "id": id,
4598 4 : "type": "ContextSourceRegistration",
4599 4 : "endpoint": "http://peer:9090",
4600 4 : "contextSourceAlias": "peer1",
4601 4 : "information": [{"entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}]}],
4602 4 : }),
4603 4 : ).await
4604 4 : .expect("seed registration");
4605 20 : let params = |kv: &[(&str, &str)]| {
4606 20 : kv.iter()
4607 24 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
4608 20 : .collect::<HashMap<String, String>>()
4609 20 : };
4610 20 : for (p, headers, expected) in [
4611 4 : (params(&[("type", "Vehicle")]), hdrs(None), true),
4612 4 : (params(&[("type", "Parking")]), hdrs(None), false),
4613 4 : (
4614 4 : params(&[("type", "Vehicle"), ("local", "true")]),
4615 4 : hdrs(None),
4616 4 : false,
4617 4 : ),
4618 4 : (
4619 4 : params(&[("type", "Vehicle")]),
4620 4 : hdrs(Some("1.1 peer1")),
4621 4 : false,
4622 4 : ),
4623 4 : (
4624 4 : params(&[("type", "Vehicle")]),
4625 4 : hdrs(Some("1.1 other")),
4626 4 : true,
4627 4 : ),
4628 4 : ] {
4629 20 : assert_eq!(
4630 20 : would_federate(&st, &t, &ctx, &p, &headers)
4631 20 : .await
4632 20 : .expect("registrations"),
4633 4 : expected,
4634 4 : "would_federate verdict for {p:?}"
4635 4 : );
4636 20 : assert_eq!(
4637 20 : active(&p)
4638 16 : && !matching_regs(&st, &t, &query_spec(&ctx, &p), &ctx, &headers)
4639 16 : .await
4640 16 : .expect("registrations")
4641 16 : .is_empty(),
4642 4 : expected,
4643 4 : "the compiled forward set must agree for {p:?}"
4644 4 : );
4645 4 : }
4646 4 : }
4647 : }
4648 :
4649 : #[cfg(test)]
4650 : mod clause_4_20 {
4651 : use super::*;
4652 :
4653 48 : fn reg(ops: &[&str]) -> FedReg {
4654 : FedReg {
4655 48 : ops: ops.iter().map(|s| (*s).to_owned()).collect(),
4656 48 : ..FedReg::default()
4657 : }
4658 48 : }
4659 :
4660 : /// Table 4.20-2: associationOps is federationOps WITHOUT the EntityMap
4661 : /// support operations (and without createEntityMapQueryTemporal, which is
4662 : /// in neither group).
4663 : #[test]
4664 4 : fn association_ops_exclude_the_entity_map_operations() {
4665 4 : let r = reg(&["associationOps"]);
4666 16 : for op in [
4667 4 : "retrieveEntity",
4668 4 : "queryEntity",
4669 4 : "deleteSubscription",
4670 4 : "retrieveContextSourceIdentity",
4671 4 : ] {
4672 16 : assert!(r.supports(op), "{op} is in associationOps");
4673 : }
4674 16 : for op in [
4675 4 : "retrieveEntityMap",
4676 4 : "updateEntityMap",
4677 4 : "deleteEntityMap",
4678 4 : "createEntityMapQueryEntity",
4679 4 : ] {
4680 16 : assert!(
4681 16 : !r.supports(op),
4682 : "{op} is NOT in associationOps (Table 4.20-2)"
4683 : );
4684 : }
4685 4 : }
4686 :
4687 : /// Table 4.20-1/2: individual names match themselves; groups match their
4688 : /// members; nothing matches createEntityMapQueryTemporal except itself.
4689 : #[test]
4690 4 : fn groups_and_individual_names() {
4691 4 : assert!(reg(&["federationOps"]).supports("retrieveEntityMap"));
4692 4 : assert!(reg(&["redirectionOps"]).supports("purgeEntity"));
4693 4 : assert!(!reg(&["redirectionOps"]).supports("createSubscription"));
4694 4 : assert!(reg(&["updateOps"]).supports("replaceAttrs"));
4695 4 : assert!(!reg(&["updateOps"]).supports("deleteEntity"));
4696 4 : assert!(reg(&["retrieveOps"]).supports("queryEntity"));
4697 4 : assert!(!reg(&["retrieveOps"]).supports("retrieveTemporal"));
4698 4 : assert!(reg(&["createEntityMapQueryTemporal"]).supports("createEntityMapQueryTemporal"));
4699 12 : for group in ["federationOps", "associationOps", "redirectionOps"] {
4700 12 : assert!(
4701 12 : !reg(&[group]).supports("createEntityMapQueryTemporal"),
4702 : "{group} does not include createEntityMapQueryTemporal"
4703 : );
4704 : }
4705 4 : }
4706 : }
4707 :
4708 : #[cfg(test)]
4709 : mod same_source_merge {
4710 : use super::*;
4711 :
4712 60 : fn reg(id: &str, endpoint: &str, attrs: Option<&[&str]>, types: &[&str]) -> FedReg {
4713 : FedReg {
4714 60 : reg_id: id.into(),
4715 60 : endpoint: endpoint.into(),
4716 60 : mode: "inclusive".into(),
4717 60 : ops: vec!["federationOps".into()],
4718 60 : attrs: attrs.map(|a| a.iter().map(|s| (*s).to_owned()).collect()),
4719 60 : ent_types: types.iter().map(|s| (*s).to_owned()).collect(),
4720 : // type-only EntityInfo, as the registration builder reads it
4721 : ent_unrestricted: true,
4722 60 : ..FedReg::default()
4723 : }
4724 60 : }
4725 :
4726 : /// Two registrations for one Context Source become one forward whose
4727 : /// scopes are the union; a different endpoint or mode stays separate.
4728 : #[test]
4729 4 : fn registrations_of_one_source_fold_into_one_forward() {
4730 4 : let merged = merge_same_source(vec![
4731 4 : reg("urn:r:1", "http://a", Some(&["speed"]), &["Vehicle"]),
4732 4 : reg(
4733 4 : "urn:r:2",
4734 4 : "http://a",
4735 4 : Some(&["heading", "speed"]),
4736 4 : &["Vehicle"],
4737 : ),
4738 4 : reg("urn:r:3", "http://b", Some(&["speed"]), &["Vehicle"]),
4739 : ]);
4740 4 : assert_eq!(merged.len(), 2);
4741 4 : assert_eq!(merged[0].reg_id, "urn:r:1");
4742 4 : assert_eq!(
4743 4 : merged[0].attrs.as_deref(),
4744 4 : Some(&["speed".to_owned(), "heading".to_owned()][..])
4745 : );
4746 4 : assert_eq!(merged[1].endpoint, "http://b");
4747 :
4748 : // equal attribute scopes: the types union
4749 4 : let merged = merge_same_source(vec![
4750 4 : reg("urn:r:1", "http://a", Some(&["speed"]), &["Vehicle"]),
4751 4 : reg("urn:r:2", "http://a", Some(&["speed"]), &["Bike"]),
4752 : ]);
4753 4 : assert_eq!(merged.len(), 1);
4754 4 : assert_eq!(merged[0].ent_types, ["Vehicle", "Bike"]);
4755 :
4756 : // 4.3.6.1, ETSI IOP_003_02_02: (Vehicle × all) and (Bike × speed)
4757 : // folded would be (both × all), a scope neither registration declared
4758 4 : let merged = merge_same_source(vec![
4759 4 : reg("urn:r:1", "http://a", None, &["Vehicle"]),
4760 4 : reg("urn:r:2", "http://a", Some(&["speed"]), &["Bike"]),
4761 : ]);
4762 4 : assert_eq!(merged.len(), 2, "an inexact union stays two forwards");
4763 :
4764 : // an unscoped registration widens the merged one to everything
4765 4 : let merged = merge_same_source(vec![
4766 4 : reg("urn:r:1", "http://a", Some(&["speed"]), &["Vehicle"]),
4767 4 : reg("urn:r:2", "http://a", None, &[]),
4768 : ]);
4769 4 : assert_eq!(merged.len(), 1);
4770 4 : assert!(merged[0].attrs.is_none());
4771 4 : assert!(merged[0].ent_types.is_empty(), "and to every type");
4772 :
4773 4 : let mut other_mode = reg("urn:r:2", "http://a", None, &[]);
4774 4 : other_mode.mode = "exclusive".into();
4775 4 : let merged = merge_same_source(vec![reg("urn:r:1", "http://a", None, &[]), other_mode]);
4776 4 : assert_eq!(merged.len(), 2, "a different mode is a different forward");
4777 4 : }
4778 :
4779 : /// 4.3.6.1: a source "may indicate that they are only willing to respond
4780 : /// to a limited subset of API operations. Context Brokers shall respect
4781 : /// this". The fold unions the entity and attribute scope, so folding two
4782 : /// registrations that declare different operations would send an
4783 : /// operation only one of them offered for the OTHER's Entities.
4784 : #[test]
4785 4 : fn registrations_of_one_source_that_offer_different_operations_do_not_fold() {
4786 4 : let mut reads = reg("urn:r:1", "http://a", None, &["Vehicle"]);
4787 4 : reads.ops = vec!["queryEntity".into()];
4788 4 : let mut writes = reg("urn:r:2", "http://a", None, &["Bike"]);
4789 4 : writes.ops = vec!["createEntity".into()];
4790 4 : let merged = merge_same_source(vec![reads, writes]);
4791 4 : assert_eq!(
4792 4 : merged.len(),
4793 : 2,
4794 : "a different operation subset is a different forward"
4795 : );
4796 4 : assert!(
4797 4 : merged[0].supports("queryEntity") && !merged[0].supports("createEntity"),
4798 : "the read registration must not gain the write registration's operation"
4799 : );
4800 4 : assert_eq!(
4801 4 : merged[0].ent_types,
4802 : ["Vehicle"],
4803 : "and must not gain its Entity scope either"
4804 : );
4805 :
4806 : // the same subset written two ways is still one source: 5.2.9 lets
4807 : // `operations` name a group, and the default IS a group
4808 4 : let mut spelled_out = reg("urn:r:2", "http://a", None, &["Bike"]);
4809 4 : spelled_out.ops = antares_model::operations::group_members("federationOps")
4810 4 : .expect("the default group")
4811 4 : .iter()
4812 76 : .map(|s| (*s).to_owned())
4813 4 : .collect();
4814 4 : let merged = merge_same_source(vec![
4815 4 : reg("urn:r:1", "http://a", None, &["Vehicle"]),
4816 4 : spelled_out,
4817 : ]);
4818 4 : assert_eq!(
4819 4 : merged.len(),
4820 : 1,
4821 : "federationOps and the names it stands for are one subset"
4822 : );
4823 4 : assert_eq!(merged[0].ent_types, ["Vehicle", "Bike"]);
4824 4 : }
4825 : }
|