LCOV - code coverage report
Current view: top level - antares-api/src - entity_map.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.2 % 563 553
Test Date: 2026-09-21 10:31:06 Functions: 63.6 % 217 138

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! One EntityMap document (5.2.39) and the rules for using one: store it
       3              : //! under its tenant with a lifetime, read it back only while it is alive,
       4              : //! take the candidate ids of a page out of it, merge the registrations a
       5              : //! distributed query reached into it, and serve a retrieve through the map
       6              : //! a client presented. The /entityMaps resource itself is `entity_maps`,
       7              : //! which composes this over the queries in `entities` and `temporal`.
       8              : 
       9              : use crate::negotiate::*;
      10              : use crate::state::AppState;
      11              : use antares_model::{NgsiError, TenantId};
      12              : use antares_store::CurrentStateDriverExt;
      13              : use antares_store::Kind;
      14              : use axum::http::{HeaderMap, StatusCode};
      15              : use axum::response::Response;
      16              : use serde_json::{json, Map, Value};
      17              : use std::collections::HashMap;
      18              : 
      19              : /// Per-tenant EntityMap cap (every buffer bounded); earliest-expiring evicted.
      20              : pub(crate) const MAX_MAPS_PER_TENANT: usize = 512;
      21              : 
      22              : /// Default lifetime when the client suggests none — 5.5.14: "the caching
      23              : /// strategy and expiry time … depend on implementation specific
      24              : /// configurations".
      25              : pub(crate) const DEFAULT_LIFETIME_SECS: i64 = 3600;
      26              : 
      27              : /// Ceiling on client-suggested lifetimes — 6.4.3.2-1: "the actual expiresAt
      28              : /// time of the EntityMap shall be set by the Context Broker or Context
      29              : /// Source, possibly overriding the requested duration".
      30              : pub(crate) const MAX_LIFETIME_SECS: i64 = 86_400;
      31              : 
      32         3206 : pub(crate) fn dt(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
      33         3206 :     chrono::DateTime::parse_from_rfc3339(s)
      34         3206 :         .ok()
      35         3206 :         .map(|d| d.with_timezone(&chrono::Utc))
      36         3206 : }
      37              : 
      38              : /// Record whose map this is, so 5.5.14's "cannot be accessed" covers a map
      39              : /// built for someone else. A broker-internal member: 5.2.39 defines none, no
      40              : /// served map carries it, and a client cannot supply one because every map
      41              : /// the broker stores is one the broker built.
      42          202 : fn stamp_subject(doc: &mut Value, tenant: &TenantId, headers: &HeaderMap) {
      43           10 :     if let (Some(o), Some(subject)) = (
      44          202 :         doc.as_object_mut(),
      45          202 :         crate::policy::subject_member(&crate::policy::subject_of(tenant, headers)),
      46           10 :     ) {
      47           10 :         o.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
      48          192 :     }
      49          202 : }
      50              : 
      51              : /// Fetch a live EntityMap; an expired one "cannot be accessed" (5.5.14).
      52              : /// Reading is a read: the row behind a map this refuses is freed by
      53              : /// `sweep_expired_maps` on the sweep tick, not by the request that found
      54              : /// it. Maps live in the store (Kind::EntityMap) so persistent modes survive
      55              : /// restarts.
      56          484 : pub(crate) async fn map_get(
      57          484 :     st: &AppState,
      58          484 :     tenant: &TenantId,
      59          484 :     id: &str,
      60          484 : ) -> Result<Option<Value>, NgsiError> {
      61          484 :     let Some(doc) = st.store.get(tenant, Kind::EntityMap, id).await? else {
      62           74 :         return Ok(None);
      63              :     };
      64          402 :     Ok(map_live(&doc).then_some(doc))
      65          484 : }
      66              : 
      67              : /// 5.5.14 is a positive condition: a map is usable only while a READABLE
      68              : /// expiry is still in the future. Judging "expired" instead lets a map whose
      69              : /// `expiresAt` is missing or unparseable outlive every ceiling. One
      70              : /// definition, so the sweep reaps exactly what a read refuses.
      71          826 : fn map_live(doc: &Value) -> bool {
      72          826 :     doc.get("expiresAt")
      73          826 :         .and_then(Value::as_str)
      74          826 :         .and_then(dt)
      75          826 :         .is_some_and(|e| e > chrono::Utc::now())
      76          826 : }
      77              : 
      78              : /// 4.22 for EntityMaps: a map a read will not serve is a map nothing can
      79              : /// reach, and this is what removes it.
      80        20062 : pub(crate) async fn sweep_expired_maps(st: &AppState, tenant: &TenantId) -> usize {
      81        20062 :     let mut dead: Vec<String> = Vec::new();
      82        20062 :     if crate::csource::walk_docs(st, tenant, Kind::EntityMap, |doc| {
      83          424 :         if !map_live(&doc) {
      84           24 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
      85           24 :                 dead.push(id.to_owned());
      86           24 :             }
      87          400 :         }
      88          424 :         Ok(())
      89          424 :     })
      90        20062 :     .await
      91        20062 :     .is_err()
      92              :     {
      93            0 :         return 0;
      94        20062 :     }
      95        20062 :     let mut n = 0;
      96        20062 :     for id in dead {
      97           24 :         if st
      98           24 :             .store
      99           24 :             .delete(tenant, Kind::EntityMap, &id)
     100           24 :             .await
     101           24 :             .unwrap_or(false)
     102           24 :         {
     103           24 :             n += 1;
     104           24 :         }
     105              :     }
     106        20062 :     n
     107        20062 : }
     108              : 
     109              : /// The map a consumption request named, or nothing.
     110              : ///
     111              : /// 5.5.14: "If an EntityMap has expired, or cannot be accessed, no inference
     112              : /// can be made as to which entities are held within the Context Sources and a
     113              : /// new one shall be created." A store that refuses the read is one way a map
     114              : /// cannot be accessed, so these paths recover the way they recover from an
     115              : /// expiry — with a new map — instead of failing the request.
     116              : /// A map built for a DIFFERENT subject is one this one cannot access, so the
     117              : /// clause's own recovery applies: a new map is created for this request. It
     118              : /// has to be that and not a refusal — the map id came from a header the
     119              : /// client may well be replaying honestly, and an error would tell it that
     120              : /// someone else's transaction exists (ADR-0020).
     121           70 : pub(crate) async fn map_if_accessible(
     122           70 :     st: &AppState,
     123           70 :     tenant: &TenantId,
     124           70 :     headers: &HeaderMap,
     125           70 :     id: &str,
     126           70 : ) -> Option<Value> {
     127           70 :     map_get(st, tenant, id)
     128           70 :         .await
     129           70 :         .ok()
     130           70 :         .flatten()
     131           70 :         .filter(|doc| crate::policy::belongs_to(doc, &crate::policy::subject_of(tenant, headers)))
     132           70 : }
     133              : 
     134              : /// The Entities of a map a given request may be answered from.
     135              : ///
     136              : /// 5.5.9.3: "the set of Entities considered for the result is fixed with the
     137              : /// initial query creating the Entity map." The map is the CANDIDATE set; the
     138              : /// request's own filters still apply on top of it, so a request that names
     139              : /// `id=` is asking for the intersection and never for the map's whole set.
     140              : /// Returned in the map's own order, which is the pagination order.
     141           46 : pub(crate) fn candidate_ids(map: &Value, params: &HashMap<String, String>) -> Vec<String> {
     142           46 :     let named: Option<std::collections::HashSet<&str>> =
     143           46 :         params.get("id").map(|s| s.split(',').collect());
     144           46 :     map["entityMap"]
     145           46 :         .as_object()
     146           46 :         .map(|o| {
     147           46 :             o.keys()
     148          220 :                 .filter(|k| named.as_ref().is_none_or(|n| n.contains(k.as_str())))
     149           46 :                 .cloned()
     150           46 :                 .collect()
     151           46 :         })
     152           46 :         .unwrap_or_default()
     153           46 : }
     154              : 
     155              : ///
     156              : /// Every store failure here is the caller's: a map the broker could not count
     157              : /// against its ceiling, or could not write, is not a map the client can be
     158              : /// handed the id of (Table 6.3.2-1 InternalError).
     159         2376 : pub(crate) async fn map_put(
     160         2376 :     st: &AppState,
     161         2376 :     tenant: &TenantId,
     162         2376 :     mut doc: Value,
     163         2376 : ) -> Result<(), NgsiError> {
     164         2376 :     let Some(id) = doc.get("id").and_then(Value::as_str).map(str::to_owned) else {
     165            0 :         return Ok(());
     166              :     };
     167              :     // 6.4.3.2-1: "the actual expiresAt time of the EntityMap shall be set by
     168              :     // the Context Broker or Context Source, possibly overriding the requested
     169              :     // duration" — the 5.14.2.4 update path carries a client-chosen instant, so
     170              :     // the ceiling binds here, at the one point every writer goes through. An
     171              :     // absent or unreadable expiry is left alone: 5.5.14 keeps it unusable.
     172         2376 :     let ceiling = chrono::Utc::now() + chrono::Duration::seconds(MAX_LIFETIME_SECS);
     173         2376 :     if doc
     174         2376 :         .get("expiresAt")
     175         2376 :         .and_then(Value::as_str)
     176         2376 :         .and_then(dt)
     177         2376 :         .is_some_and(|e| e > ceiling)
     178         2092 :     {
     179         2092 :         doc["expiresAt"] = json!(ceiling.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
     180         2092 :     }
     181              :     // The ceiling decides whether a NEW map fits; rewriting a map that is
     182              :     // already stored replaces a row that is already counted, so the paging
     183              :     // path neither lists the tenant's maps nor evicts one per page. A count
     184              :     // the store refuses leaves the ceiling unenforceable, and an unbounded
     185              :     // buffer is not the safer half of that choice: the write is refused.
     186         2376 :     if st.store.get(tenant, Kind::EntityMap, &id).await?.is_none() {
     187         2310 :         let existing = st.store.list(tenant, Kind::EntityMap).await?;
     188         2308 :         if existing.len() >= MAX_MAPS_PER_TENANT {
     189              :             // eviction order is a heuristic — earliest expiresAt string wins
     190           32 :             if let Some(victim) = existing
     191           32 :                 .iter()
     192        16352 :                 .min_by(|a, b| {
     193        16352 :                     a["expiresAt"]
     194        16352 :                         .as_str()
     195        16352 :                         .unwrap_or("")
     196        16352 :                         .cmp(b["expiresAt"].as_str().unwrap_or(""))
     197        16352 :                 })
     198           32 :                 .and_then(|d| d.get("id").and_then(Value::as_str))
     199              :             {
     200           32 :                 st.store.delete(tenant, Kind::EntityMap, victim).await?;
     201            0 :             }
     202         2276 :         }
     203           66 :     }
     204         2374 :     let updated = st
     205         2374 :         .store
     206         2374 :         .mutate(tenant, Kind::EntityMap, &id, |d| {
     207           66 :             *d = doc.clone();
     208           66 :             Ok::<_, std::convert::Infallible>(())
     209           66 :         })
     210         2374 :         .await?
     211         2374 :         .is_some();
     212         2374 :     if !updated {
     213         2308 :         st.store.create(tenant, Kind::EntityMap, &id, doc).await?;
     214           66 :     }
     215         2374 :     Ok(())
     216         2376 : }
     217              : 
     218              : /// 5.14.3.4: "If the NGSI-LD endpoint does not know about a matching EntityMap
     219              : /// for the EntityMap ID, then an error of type ResourceNotFound shall be
     220              : /// raised." What the endpoint knows about is what [`map_get`] serves, so the
     221              : /// delete reads through it: an expired map is beyond access (5.5.14) for the
     222              : /// retrieve and for the delete alike, and `map_get` prunes the row on the way
     223              : /// past, which leaves nothing for a later sweep to collect.
     224          112 : pub(crate) async fn map_delete(
     225          112 :     st: &AppState,
     226          112 :     tenant: &TenantId,
     227          112 :     id: &str,
     228          112 : ) -> Result<bool, NgsiError> {
     229          112 :     if map_get(st, tenant, id).await?.is_none() {
     230            4 :         return Ok(false);
     231          108 :     }
     232          108 :     st.store.delete(tenant, Kind::EntityMap, id).await
     233          112 : }
     234              : 
     235              : /// Parse an ISO 8601 duration (entityMapLifetime, Table 6.4.3.2-1) to whole
     236              : /// seconds; years/months are approximated (365/30 days), fractions rejected.
     237          154 : pub(crate) fn iso8601_secs(s: &str) -> Option<i64> {
     238              :     // A lifetime is a span, so the calendar components are weighed at their
     239              :     // nominal length — a year 365 days, a month 30 — and a fractional or
     240              :     // absent component has no whole-second span to weigh.
     241          154 :     let d = antares_model::parse_iso_duration(s).filter(|d| d.whole && !d.empty)?;
     242           48 :     [
     243           48 :         (d.years, 31_536_000),
     244           48 :         (d.months, 2_592_000),
     245           48 :         (d.weeks, 604_800),
     246           48 :         (d.days, 86_400),
     247           48 :         (d.hours, 3_600),
     248           48 :         (d.minutes, 60),
     249           48 :         (d.seconds, 1),
     250           48 :     ]
     251           48 :     .into_iter()
     252          312 :     .try_fold(0i64, |acc, (n, per)| {
     253          312 :         acc.checked_add((n as i64).checked_mul(per)?)
     254          312 :     })
     255          154 : }
     256              : 
     257              : /// The expiresAt the broker assigns (5.2.39): now + suggested lifetime,
     258              : /// bounded by the broker's ceiling; the default applies when none is given.
     259          224 : pub(crate) fn expires_at(params: &HashMap<String, String>) -> Result<String, NgsiError> {
     260          224 :     let secs = match params.get("entityMapLifetime") {
     261           22 :         Some(d) => iso8601_secs(d)
     262           22 :             .ok_or_else(|| {
     263           10 :                 NgsiError::BadRequestData(format!(
     264           10 :                     "entityMapLifetime is not an ISO 8601 duration: {d:?}"
     265           10 :                 ))
     266           10 :             })?
     267              :             // A zero or negative suggestion would answer 201 with a map that
     268              :             // 5.5.14 already forbids anyone from accessing, so the broker
     269              :             // floor applies as well as the ceiling.
     270           12 :             .clamp(1, MAX_LIFETIME_SECS),
     271          202 :         None => DEFAULT_LIFETIME_SECS,
     272              :     };
     273          214 :     Ok((chrono::Utc::now() + chrono::Duration::seconds(secs))
     274          214 :         .to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
     275          224 : }
     276              : 
     277              : /// 5.14.1.4 / 5.14.2.4 / 5.14.3.4: every /entityMaps/{id} method opens the
     278              : /// same way — the tenant it runs in, `local` (6.3.18) as the only parameter
     279              : /// this resource takes, and an id that must be a valid URI before the store
     280              : /// is touched at all.
     281          266 : pub(crate) fn open_map(
     282          266 :     params: &HashMap<String, String>,
     283          266 :     headers: &HeaderMap,
     284          266 :     id: &str,
     285          266 : ) -> ApiResult<TenantId> {
     286          266 :     let tenant = tenant_from(headers)?;
     287          266 :     check_params(params, &["local"])?;
     288          266 :     map_id_check(id)?;
     289          254 :     Ok(tenant)
     290          266 : }
     291              : 
     292              : /// 5.14.1.4 / 5.14.3.4: "If the EntityMap id is not present or it is not a
     293              : /// valid URI, then an error of type BadRequestData shall be raised."
     294          304 : pub(crate) fn map_id_check(id: &str) -> Result<(), NgsiError> {
     295          304 :     antares_model::EntityId::new(id)
     296          304 :         .map(|_| ())
     297          304 :         .map_err(|_| NgsiError::BadRequestData(format!("EntityMap id is not a valid URI: {id:?}")))
     298          304 : }
     299              : 
     300              : /// Table 5.2.39-2 on the way in: a returned EntityMap's `entityMap` is "a
     301              : /// set of key-value pairs whose keys shall be strings representing Entity
     302              : /// ids", so a key from a Context Source is checked before it becomes a key
     303              : /// of the map this broker stores under its own id and serves from
     304              : /// `/entityMaps/{id}`. Per key, not per peer: a source that names one
     305              : /// unusable id still contributes its usable ones. `@none` fails the check
     306              : /// with everything else, which is what it deserves here — it is the PEER's
     307              : /// "held locally" marker (5.2.39) and stands for no Entity id on this side.
     308              : /// `cap` is the ceiling the local half of the map already carries
     309              : /// (`st.max_limit`), so no one Context Source is larger than the broker.
     310           10 : fn peer_entity_ids(remote: &Value, cap: usize) -> Vec<&String> {
     311           10 :     remote
     312           10 :         .get("entityMap")
     313           10 :         .and_then(Value::as_object)
     314           10 :         .into_iter()
     315           10 :         .flat_map(serde_json::Map::keys)
     316         2014 :         .filter(|k| antares_model::EntityId::new(k).is_ok())
     317           10 :         .take(cap)
     318           10 :         .collect()
     319           10 : }
     320              : 
     321              : /// 5.14.4.4 and 5.14.5.4 end the same way: "The mapping between the Context
     322              : /// Source Registration and the EntityMap Id is added to the linkedMaps
     323              : /// element of the local EntityMap and for the Entity ids included in the
     324              : /// returned Entity Maps a mapping to the Context Source Registration is added
     325              : /// to the entityMap element of the local EntityMap. The local EntityMap is
     326              : /// stored and made accessible based on its identifier." The two operations
     327              : /// differ only in which registration operation they ask for and which peer
     328              : /// resource carries it.
     329          184 : pub(crate) async fn merge_and_store_map(
     330          184 :     st: &AppState,
     331          184 :     tenant: &TenantId,
     332          184 :     headers: &HeaderMap,
     333          184 :     ctx: &antares_jsonld::Context,
     334          184 :     params: &HashMap<String, String>,
     335          184 :     temporal: bool,
     336          184 :     mut emap: Map<String, Value>,
     337          184 : ) -> ApiResult<Value> {
     338          184 :     let (op, path) = if temporal {
     339           26 :         ("createEntityMapQueryTemporal", "temporal/entityMaps")
     340              :     } else {
     341          158 :         ("createEntityMapQueryEntity", "entityMaps")
     342              :     };
     343          184 :     let mut linked = Map::new();
     344              :     // 5.5.13 local=true: no Context Source Registration is considered, so
     345              :     // nothing merges in and linkedMaps stays empty.
     346          184 :     if params.get("local").map(String::as_str) != Some("true") {
     347          172 :         let split = params.get("splitEntities").map(String::as_str) == Some("true");
     348           10 :         for (reg_id, remote) in
     349          172 :             crate::federation::fed_entity_maps(st, tenant, headers, ctx, params, split, op, path)
     350          172 :                 .await?
     351              :         {
     352         2006 :             for eid in peer_entity_ids(&remote, st.max_limit) {
     353         2006 :                 if let Some(a) = emap
     354         2006 :                     .entry(eid.clone())
     355         2006 :                     .or_insert_with(|| json!([]))
     356         2006 :                     .as_array_mut()
     357         2006 :                 {
     358         2006 :                     a.push(json!(reg_id.clone()));
     359         2006 :                 }
     360              :             }
     361              :             // Table 5.2.39-1 restricts an EntityMap id to a valid URI, and
     362              :             // 5.14.1.4 refuses one that is not from a client. The peer's id
     363              :             // travels back out as the `NGSILD-EntityMap` header of every
     364              :             // later forwarded page (`federation::map_gate`), so it is held
     365              :             // to the same rule; without a usable id the registration simply
     366              :             // carries no linked map and the peer re-runs its own query.
     367           10 :             match remote.get("id").and_then(Value::as_str) {
     368           10 :                 Some(mid) if map_id_check(mid).is_ok() => {
     369            8 :                     linked.insert(reg_id, json!(mid));
     370            8 :                 }
     371            2 :                 _ => {}
     372              :             }
     373              :         }
     374           12 :     }
     375          184 :     let mut doc = json!({
     376          184 :         "id": format!("urn:ngsi-ld:entitymap:{}", uuid::Uuid::new_v4()),
     377          184 :         "type": "EntityMap",
     378          184 :         "expiresAt": expires_at(params)?,
     379          178 :         "entityMap": Value::Object(emap),
     380          178 :         "linkedMaps": Value::Object(linked),
     381              :     });
     382          178 :     stamp_subject(&mut doc, tenant, headers);
     383          178 :     map_put(st, tenant, doc.clone()).await?;
     384          176 :     Ok(doc)
     385          184 : }
     386              : 
     387              : /// 5.7.1.4 / 5.7.3.4: the EntityMap created for a single-Entity retrieve —
     388              : /// its one entry lists "@none" when Attribute data is held locally plus
     389              : /// every matching Context Source Registration supporting the retrieve
     390              : /// operation ("only the retrieved Entity Map shall be used to determine
     391              : /// which Context Source Registrations match the Entity ID").
     392              : #[allow(clippy::too_many_arguments)] // one param per 5.7.1.4 input
     393           24 : pub(crate) async fn build_retrieve_map(
     394           24 :     st: &AppState,
     395           24 :     tenant: &TenantId,
     396           24 :     ctx: &antares_jsonld::Context,
     397           24 :     headers: &HeaderMap,
     398           24 :     id: &str,
     399           24 :     params: &HashMap<String, String>,
     400           24 :     temporal: bool,
     401           24 :     local_held: bool,
     402           24 : ) -> Result<Value, NgsiError> {
     403           24 :     let mut srcs: Vec<Value> = Vec::new();
     404           24 :     if local_held {
     405           24 :         srcs.push(json!("@none"));
     406           24 :     }
     407           24 :     if crate::federation::active(params) {
     408           22 :         let spec = crate::registry::CsrSpec {
     409           22 :             ids: Some(vec![id.to_owned()]),
     410           22 :             ..Default::default()
     411           22 :         };
     412           22 :         for reg in crate::federation::matching_regs(st, tenant, &spec, ctx, headers).await? {
     413            0 :             let ok = if temporal {
     414            0 :                 reg.supports("retrieveTemporal")
     415              :             } else {
     416            0 :                 reg.read_op().is_some()
     417              :             };
     418            0 :             if ok {
     419            0 :                 srcs.push(json!(reg.reg_id));
     420            0 :             }
     421              :         }
     422            2 :     }
     423           24 :     let mut emap = Map::new();
     424           24 :     if !srcs.is_empty() {
     425           24 :         emap.insert(id.to_owned(), Value::Array(srcs));
     426           24 :     }
     427           24 :     let mut doc = json!({
     428           24 :         "id": format!("urn:ngsi-ld:entitymap:{}", uuid::Uuid::new_v4()),
     429           24 :         "type": "EntityMap",
     430           24 :         "expiresAt": expires_at(params)?,
     431           24 :         "entityMap": Value::Object(emap),
     432           24 :         "linkedMaps": {},
     433              :     });
     434           24 :     stamp_subject(&mut doc, tenant, headers);
     435           24 :     map_put(st, tenant, doc.clone()).await?;
     436           24 :     Ok(doc)
     437           24 : }
     438              : 
     439              : /// The NGSILD-EntityMap response header: the resource URI of the map that
     440              : /// determined the sources of this response (6.3.17).
     441           30 : pub(crate) fn set_map_header(resp: &mut Response, mid: &str) {
     442           30 :     if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{mid}").parse() {
     443           30 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     444           30 :     }
     445           30 : }
     446              : 
     447              : /// 5.7.1.4 / 5.7.3.4 EntityMap usage on a single-Entity retrieve: a supplied
     448              : /// NGSILD-EntityMap location is retrieved and, if live, is the only source
     449              : /// used to determine which registrations match; an unknown or expired
     450              : /// reference — or the `entityMap=true` flag — creates a new map, whose
     451              : /// location is returned in the NGSILD-EntityMap response header. The two
     452              : /// clauses word that rule identically and part company only over what
     453              : /// "held locally" reads and which operation a registration must support,
     454              : /// and both of those follow from `temporal` — so the rule is read once
     455              : /// here and the retrieve itself is the caller's `inner`.
     456          882 : pub(crate) async fn retrieve_with_map<F, Fut>(
     457          882 :     st: &AppState,
     458          882 :     id: &str,
     459          882 :     params: &HashMap<String, String>,
     460          882 :     headers: &HeaderMap,
     461          882 :     temporal: bool,
     462          882 :     inner: F,
     463          882 : ) -> ApiResult<Response>
     464          882 : where
     465          882 :     F: Fn(Option<Value>) -> Fut,
     466          882 :     Fut: std::future::Future<Output = ApiResult<Response>>,
     467          882 : {
     468          882 :     let tenant = tenant_from(headers)?;
     469          882 :     let map_ref = single_header(headers, "NGSILD-EntityMap")?
     470          880 :         .map(|r| r.rsplit('/').next().unwrap_or(&r).to_owned());
     471          880 :     let existing = match map_ref.as_deref() {
     472           12 :         Some(mid) => map_if_accessible(st, &tenant, headers, mid).await,
     473          868 :         None => None,
     474              :     };
     475          880 :     if let Some(map) = existing {
     476            6 :         let mut resp = inner(Some(map)).await?;
     477            6 :         set_map_header(&mut resp, &map_ref.unwrap_or_default());
     478            6 :         return Ok(resp);
     479          874 :     }
     480          874 :     let want_map = map_ref.is_some() || params.get("entityMap").map(String::as_str) == Some("true");
     481          874 :     let mut resp = inner(None).await?;
     482          710 :     if want_map && resp.status().is_success() {
     483           24 :         let ctx = request_context(&st.loader, headers).await?;
     484           24 :         let local_held = if temporal {
     485            6 :             st.temporal
     486            6 :                 .get_temporal(
     487            6 :                     &tenant,
     488            6 :                     id,
     489            6 :                     &antares_store::filter::TemporalFilter::default(),
     490            6 :                 )
     491            6 :                 .await?
     492            6 :                 .is_some()
     493              :         } else {
     494           18 :             st.store.get(&tenant, Kind::Entity, id).await?.is_some()
     495              :         };
     496           24 :         let map = build_retrieve_map(st, &tenant, &ctx, headers, id, params, temporal, local_held)
     497           24 :             .await?;
     498           24 :         if let Some(mid) = map.get("id").and_then(Value::as_str) {
     499           24 :             set_map_header(&mut resp, mid);
     500           24 :         }
     501          686 :     }
     502          710 :     Ok(resp)
     503          882 : }
     504              : 
     505              : /// 201 + the EntityMap body + the NGSILD-EntityMap header carrying the
     506              : /// resource URI of the created map (6.34.3.1 / 6.35.3.1).
     507          128 : pub(crate) fn created_response(
     508          128 :     doc: Value,
     509          128 :     ctx: &antares_jsonld::Context,
     510          128 :     accept: Accept,
     511          128 :     tenant: &TenantId,
     512          128 : ) -> Response {
     513          128 :     let uri = format!(
     514              :         "/ngsi-ld/v1/entityMaps/{}",
     515          128 :         doc.get("id").and_then(Value::as_str).unwrap_or_default()
     516              :     );
     517          128 :     let mut doc = doc;
     518          128 :     crate::policy::strip_internal(&mut doc);
     519          128 :     let mut resp = respond(StatusCode::CREATED, doc, ctx, accept, tenant);
     520          128 :     if let Ok(v) = uri.parse() {
     521          128 :         resp.headers_mut().insert("NGSILD-EntityMap", v);
     522          128 :     }
     523          128 :     resp
     524          128 : }
     525              : 
     526              : #[cfg(test)]
     527              : mod tests {
     528              :     use super::*;
     529              :     use antares_store::Kind;
     530              :     use serde_json::json;
     531              : 
     532              :     /// `map_get` with its store failure unwrapped: these tests drive a
     533              :     /// working store, where a refusal would be the test's own bug.
     534           48 :     async fn map_read(st: &AppState, tenant: &TenantId, id: &str) -> Option<Value> {
     535           48 :         map_get(st, tenant, id).await.expect("the store answers")
     536           48 :     }
     537              : 
     538              :     /// 5.5.14 with RFC 9110 §9.2.1: reading a map the broker will not serve
     539              :     /// must not write. The row survives the read, and the sweep is what
     540              :     /// frees it.
     541              :     #[tokio::test]
     542            4 :     async fn an_expired_map_is_refused_by_a_read_that_writes_nothing() {
     543            4 :         let st = crate::wired_state("antares-map-sweep").await;
     544            4 :         let t = TenantId::default();
     545            4 :         let id = "urn:ngsi-ld:EntityMap:stale";
     546            4 :         st.store
     547            4 :             .create(
     548            4 :                 &t,
     549            4 :                 Kind::EntityMap,
     550            4 :                 id,
     551            4 :                 json!({"id": id, "expiresAt": "2000-01-01T00:00:00Z"}),
     552            4 :             )
     553            4 :             .await
     554            4 :             .expect("seed");
     555              : 
     556            4 :         assert!(map_read(&st, &t, id).await.is_none(), "5.5.14: not served");
     557            4 :         assert!(
     558            4 :             st.store
     559            4 :                 .get(&t, Kind::EntityMap, id)
     560            4 :                 .await
     561            4 :                 .expect("store")
     562            4 :                 .is_some(),
     563              :             "the read deleted the row: a GET must be safe (RFC 9110 9.2.1)"
     564              :         );
     565              : 
     566            4 :         assert_eq!(sweep_expired_maps(&st, &t).await, 1, "the sweep reaps it");
     567            4 :         assert!(
     568            4 :             st.store
     569            4 :                 .get(&t, Kind::EntityMap, id)
     570            4 :                 .await
     571            4 :                 .expect("store")
     572            4 :                 .is_none(),
     573            4 :             "the sweep left the row behind"
     574            4 :         );
     575            4 :     }
     576              : 
     577              :     /// The sweep removes exactly what a read refuses — never a map still in
     578              :     /// its lifetime.
     579              :     #[tokio::test]
     580            4 :     async fn the_sweep_keeps_every_map_a_read_would_still_serve() {
     581            4 :         let st = crate::wired_state("antares-map-sweep-live").await;
     582            4 :         let t = TenantId::default();
     583            4 :         let live = "urn:ngsi-ld:EntityMap:live";
     584            4 :         st.store
     585            4 :             .create(
     586            4 :                 &t,
     587            4 :                 Kind::EntityMap,
     588            4 :                 live,
     589            4 :                 json!({"id": live, "expiresAt": "2999-01-01T00:00:00Z"}),
     590            4 :             )
     591            4 :             .await
     592            4 :             .expect("seed");
     593            4 :         assert_eq!(
     594            4 :             sweep_expired_maps(&st, &t).await,
     595              :             0,
     596              :             "a live map was reaped"
     597              :         );
     598            4 :         assert!(map_read(&st, &t, live).await.is_some(), "still served");
     599            4 :     }
     600              : 
     601              :     /// Table 6.4.3.2-1: entityMapLifetime is an ISO 8601 duration.
     602              :     #[test]
     603            4 :     fn clause_5_14_4_lifetime_parse() {
     604            4 :         assert_eq!(iso8601_secs("PT1H"), Some(3600));
     605            4 :         assert_eq!(iso8601_secs("PT90S"), Some(90));
     606            4 :         assert_eq!(iso8601_secs("P1DT2H3M4S"), Some(93784));
     607            4 :         assert_eq!(iso8601_secs("P2W"), Some(1_209_600));
     608              :         // invalid shapes are rejected (→ 400 at the handler)
     609           28 :         for bad in ["", "P", "PT", "1H", "PT1X", "PT1.5S", "PT1"] {
     610           28 :             assert_eq!(iso8601_secs(bad), None, "{bad:?}");
     611              :         }
     612            4 :     }
     613              : 
     614              :     /// Table 6.4.3.2-1: entityMapLifetime arrives on the query string, so
     615              :     /// the parser is attacker-facing — every hostile shape must return None
     616              :     /// (a 400) rather than panic, wrap or saturate.
     617              :     #[test]
     618            4 :     fn clause_5_14_4_lifetime_hostile_inputs() {
     619           64 :         for bad in [
     620            4 :             "P-1D",                    // negative component
     621            4 :             "-P1D",                    // negative duration
     622            4 :             "P+1D",                    // signed component
     623            4 :             "p1d",                     // lower case designators
     624            4 :             " PT1H",                   // leading whitespace
     625            4 :             "PT1H ",                   // trailing whitespace
     626            4 :             "P1DT",                    // empty time part
     627            4 :             "PT99999999999999999999S", // digit run past i64
     628            4 :             "P9999999999999Y",         // multiplication overflow
     629            4 :             "P92233720368547758S",     // addition overflow after scaling
     630            4 :             "PT1H1",                   // trailing digits, no designator
     631            4 :             "P١D",                     // non-ASCII digit
     632            4 :             "P1D\u{0}",                // embedded NUL
     633            4 :             "PT,5S",                   // comma fraction
     634            4 :             "P1S",                     // time designator in the date part
     635            4 :             "PT1D",                    // date designator in the time part
     636            4 :         ] {
     637           64 :             assert_eq!(iso8601_secs(bad), None, "{bad:?} must not parse");
     638              :         }
     639              :         // the whole i64 range is walked without panicking
     640            4 :         assert_eq!(iso8601_secs(&format!("PT{}S", i64::MAX)), Some(i64::MAX));
     641            4 :         assert_eq!(iso8601_secs(&format!("PT{}S", u64::MAX)), None);
     642            4 :         assert_eq!(iso8601_secs("PT0S"), Some(0));
     643            4 :     }
     644              : 
     645           16 :     fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
     646           16 :         pairs
     647           16 :             .iter()
     648           16 :             .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
     649           16 :             .collect()
     650           16 :     }
     651              : 
     652              :     /// 6.4.3.2-1: "the actual expiresAt time of the EntityMap shall be set by
     653              :     /// the Context Broker or Context Source, possibly overriding the
     654              :     /// requested duration" — the client suggestion is bounded above by the
     655              :     /// broker ceiling and below by a lifetime the map can actually be used
     656              :     /// for; an unparseable duration is BadRequestData.
     657              :     #[test]
     658            4 :     fn clause_5_14_4_expires_at_is_broker_bounded() {
     659            4 :         let now = chrono::Utc::now();
     660           12 :         let at = |p: &[(&str, &str)]| {
     661           12 :             let s = expires_at(&params(p)).expect("expiry");
     662           12 :             dt(&s).expect("RFC 3339 expiry")
     663           12 :         };
     664            4 :         let default = at(&[]);
     665            4 :         assert!(
     666            4 :             (default - now).num_seconds() >= DEFAULT_LIFETIME_SECS - 5
     667            4 :                 && (default - now).num_seconds() <= DEFAULT_LIFETIME_SECS + 5,
     668              :             "no suggestion → the default lifetime"
     669              :         );
     670            4 :         let capped = at(&[("entityMapLifetime", "P30D")]);
     671            4 :         assert!(
     672            4 :             (capped - now).num_seconds() <= MAX_LIFETIME_SECS,
     673              :             "a client cannot exceed the broker ceiling"
     674              :         );
     675            4 :         let zero = at(&[("entityMapLifetime", "PT0S")]);
     676            4 :         assert!(
     677            4 :             zero > now,
     678              :             "a zero lifetime would return 201 for a map that is already \
     679              :              unusable (5.5.14): {zero}"
     680              :         );
     681            4 :         match expires_at(&params(&[("entityMapLifetime", "yesterday")])) {
     682            4 :             Err(NgsiError::BadRequestData(_)) => {}
     683            0 :             other => panic!("an invalid duration must be BadRequestData: {other:?}"),
     684              :         }
     685            4 :     }
     686              : 
     687              :     /// 5.14: EntityMaps are per-tenant resources — an EntityMap created
     688              :     /// under one tenant is invisible and undeletable from another (4.14
     689              :     /// multi-tenancy: "an NGSI-LD system shall behave as if the tenants were
     690              :     /// separate systems").
     691              :     #[tokio::test]
     692            4 :     async fn clause_5_14_maps_are_tenant_scoped() {
     693            4 :         let st = AppState::new("antares-em-unit".into());
     694            4 :         let a = TenantId::new("alpha").expect("tenant");
     695            4 :         let b = TenantId::new("beta").expect("tenant");
     696            4 :         let id = "urn:ngsi-ld:entitymap:t1";
     697            4 :         map_put(&st, &a, live_map(id)).await.expect("stored");
     698            4 :         assert!(map_read(&st, &a, id).await.is_some());
     699            4 :         assert!(
     700            4 :             map_read(&st, &b, id).await.is_none(),
     701              :             "another tenant must not read the map"
     702              :         );
     703            4 :         assert!(
     704            4 :             !map_delete(&st, &b, id).await.expect("delete"),
     705              :             "another tenant must not delete the map"
     706              :         );
     707            4 :         assert!(
     708            4 :             map_read(&st, &a, id).await.is_some(),
     709              :             "the owner still has its map"
     710              :         );
     711            4 :         assert!(map_delete(&st, &a, id).await.expect("delete"));
     712            4 :         assert!(map_read(&st, &a, id).await.is_none());
     713            4 :     }
     714              : 
     715         2112 :     fn live_map(id: &str) -> Value {
     716         2112 :         json!({
     717         2112 :             "id": id,
     718         2112 :             "type": "EntityMap",
     719         2112 :             "expiresAt": (chrono::Utc::now() + chrono::Duration::seconds(600))
     720         2112 :                 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
     721         2112 :             "entityMap": {},
     722         2112 :             "linkedMaps": {},
     723              :         })
     724         2112 :     }
     725              : 
     726              :     /// 5.5.14: an expired EntityMap "cannot be accessed" — it is never
     727              :     /// served, and a map whose expiry cannot be read is treated the same way
     728              :     /// rather than living forever.
     729              :     #[tokio::test]
     730            4 :     async fn clause_5_5_14_expired_maps_are_never_served() {
     731            4 :         let st = AppState::new("antares-em-exp".into());
     732            4 :         let t = TenantId::default();
     733            4 :         let mut past = live_map("urn:ngsi-ld:entitymap:past");
     734            4 :         past["expiresAt"] = json!("2020-01-01T00:00:00.000Z");
     735            4 :         map_put(&st, &t, past).await.expect("stored");
     736            4 :         assert!(map_read(&st, &t, "urn:ngsi-ld:entitymap:past")
     737            4 :             .await
     738            4 :             .is_none());
     739            4 :         assert!(
     740            4 :             st.store
     741            4 :                 .get(&t, Kind::EntityMap, "urn:ngsi-ld:entitymap:past")
     742            4 :                 .await
     743            4 :                 .expect("store")
     744            4 :                 .is_some(),
     745              :             "the read pruned the row: reading must not write"
     746              :         );
     747           12 :         for (id, expiry) in [
     748            4 :             ("urn:ngsi-ld:entitymap:none", None),
     749            4 :             ("urn:ngsi-ld:entitymap:junk", Some(json!("whenever"))),
     750            4 :             ("urn:ngsi-ld:entitymap:num", Some(json!(0))),
     751            4 :         ] {
     752           12 :             let mut doc = live_map(id);
     753           12 :             match expiry {
     754            8 :                 Some(v) => doc["expiresAt"] = v,
     755            4 :                 None => {
     756            4 :                     doc.as_object_mut().expect("object").remove("expiresAt");
     757            4 :                 }
     758            4 :             }
     759           12 :             map_put(&st, &t, doc).await.expect("stored");
     760           12 :             assert!(
     761           12 :                 map_read(&st, &t, id).await.is_none(),
     762            4 :                 "{id} has no readable expiry and must not be served"
     763            4 :             );
     764            4 :         }
     765            4 :         // What no read will serve, the sweep frees: the four seeded above.
     766            4 :         assert_eq!(sweep_expired_maps(&st, &t).await, 4);
     767            4 :     }
     768              : 
     769              :     /// 5.14.1.1 storage: every buffer is bounded — the per-tenant EntityMap
     770              :     /// registry has a ceiling, and filling it evicts rather than growing.
     771              :     #[tokio::test]
     772            4 :     async fn clause_5_14_1_map_registry_is_bounded() {
     773            4 :         let st = AppState::new("antares-em-cap".into());
     774            4 :         let t = TenantId::default();
     775         2080 :         for i in 0..MAX_MAPS_PER_TENANT + 8 {
     776         2080 :             let mut doc = live_map(&format!("urn:ngsi-ld:entitymap:{i:04}"));
     777              :             // earliest expiry first, so the eviction victim is deterministic
     778         2080 :             doc["expiresAt"] = json!(format!("2099-01-01T00:00:{:02}.000Z", i % 60));
     779         2080 :             map_put(&st, &t, doc).await.expect("stored");
     780         2080 :             assert!(
     781         2080 :                 st.store
     782         2080 :                     .list(&t, Kind::EntityMap)
     783         2080 :                     .await
     784         2080 :                     .expect("list")
     785         2080 :                     .len()
     786              :                     <= MAX_MAPS_PER_TENANT,
     787              :                 "the registry exceeded its ceiling at {i}"
     788              :             );
     789              :         }
     790              :         // re-storing a known id is an update, never an eviction
     791            4 :         let before = st
     792            4 :             .store
     793            4 :             .list(&t, Kind::EntityMap)
     794            4 :             .await
     795            4 :             .expect("list")
     796            4 :             .len();
     797            4 :         let known = st.store.list(&t, Kind::EntityMap).await.expect("list")[0]["id"]
     798            4 :             .as_str()
     799            4 :             .expect("id")
     800            4 :             .to_owned();
     801            4 :         map_put(&st, &t, live_map(&known)).await.expect("stored");
     802            4 :         assert_eq!(
     803            4 :             st.store
     804            4 :                 .list(&t, Kind::EntityMap)
     805            4 :                 .await
     806            4 :                 .expect("list")
     807            4 :                 .len(),
     808            4 :             before
     809            4 :         );
     810            4 :     }
     811              : 
     812              :     /// 5.5.14 + Table 6.4.3.2-1: "the actual expiresAt time of the EntityMap
     813              :     /// shall be set by the Context Broker or Context Source, possibly
     814              :     /// overriding the requested duration" — the 5.14.2.4 update path writes a
     815              :     /// client-chosen instant, so the broker ceiling binds when the map is
     816              :     /// stored, not only when it is created.
     817              :     #[tokio::test]
     818            4 :     async fn clause_5_5_14_stored_expiry_never_exceeds_the_broker_ceiling() {
     819            4 :         let st = AppState::new("antares-em-clamp".into());
     820            4 :         let t = TenantId::default();
     821            4 :         let ceiling = chrono::Utc::now() + chrono::Duration::seconds(MAX_LIFETIME_SECS);
     822            4 :         let far = "urn:ngsi-ld:entitymap:far";
     823            4 :         let mut doc = live_map(far);
     824            4 :         doc["expiresAt"] = json!("2099-01-01T00:00:00.000Z");
     825            4 :         map_put(&st, &t, doc).await.expect("stored");
     826            4 :         let stored = map_read(&st, &t, far)
     827            4 :             .await
     828            4 :             .expect("a clamped map is still live");
     829            4 :         let at = dt(stored["expiresAt"].as_str().expect("expiresAt")).expect("RFC 3339 expiry");
     830            4 :         assert!(
     831            4 :             at <= ceiling + chrono::Duration::seconds(5),
     832              :             "a client cannot pin an EntityMap past the broker ceiling: {at}"
     833              :         );
     834            4 :         assert!(at > chrono::Utc::now(), "the map stays usable: {at}");
     835              :         // an expiry inside the ceiling is stored verbatim, not rewritten
     836            4 :         let near = "urn:ngsi-ld:entitymap:near";
     837            4 :         let doc = live_map(near);
     838            4 :         let want = doc["expiresAt"].clone();
     839            4 :         map_put(&st, &t, doc).await.expect("stored");
     840            4 :         assert_eq!(
     841            4 :             map_read(&st, &t, near).await.expect("live")["expiresAt"],
     842            4 :             want
     843            4 :         );
     844            4 :     }
     845              : }
        

Generated by: LCOV version 2.0-1