Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! NGSI-LD data model (ETSI CIM 009 V1.9.1).
3 : //!
4 : //! Shapes and invariants only: no I/O, no clocks, no config.
5 : //!
6 : #![doc = include_str!("../README.md")]
7 : // every surface calls into this crate on a request path, where a
8 : // panic is a remote DoS. Outside tests it may not unwrap, expect, panic,
9 : // index out of bounds or overflow.
10 : #![cfg_attr(
11 : not(test),
12 : deny(
13 : clippy::unwrap_used,
14 : clippy::expect_used,
15 : clippy::panic,
16 : clippy::indexing_slicing,
17 : clippy::arithmetic_side_effects
18 : )
19 : )]
20 : #![deny(missing_docs)]
21 :
22 : pub mod error;
23 : pub mod id;
24 : pub mod operations;
25 :
26 : pub use error::{NgsiError, ProblemDetails};
27 : pub use id::{EntityId, TenantId};
28 :
29 : /// API root path (CIM 009 clause 6.2).
30 : pub const API_ROOT: &str = "/ngsi-ld/v1";
31 :
32 : /// Egress key order for a served payload: every object serializes `id`
33 : /// then `type` first, recursively (an attribute object leads with
34 : /// `"type": "Property"`, a GeoJSON Feature with `id`/`type` — the order the
35 : /// spec's own examples print). Cosmetic only: RFC 8259 objects are unordered
36 : /// and CIM 009 4.5.1 mandates presence, not position. Applied ONLY at egress
37 : /// (responses and notifications) — internal serialization (storage, temporal
38 : /// diff) stays byte-stable alphabetical and must not use this.
39 : pub struct SpecOrder<'a>(pub &'a serde_json::Value);
40 :
41 : impl serde::Serialize for SpecOrder<'_> {
42 295886 : fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
43 : use serde::ser::SerializeMap;
44 : use serde_json::Value;
45 295886 : match self.0 {
46 73074 : Value::Object(m) => {
47 73074 : let mut map = s.serialize_map(Some(m.len()))?;
48 146148 : for k in ["id", "type"] {
49 146148 : if let Some(v) = m.get(k) {
50 97620 : map.serialize_entry(k, &SpecOrder(v))?;
51 48528 : }
52 : }
53 244538 : for (k, v) in m {
54 244538 : if k != "id" && k != "type" {
55 146918 : map.serialize_entry(k, &SpecOrder(v))?;
56 97620 : }
57 : }
58 73074 : map.end()
59 : }
60 18563 : Value::Array(a) => s.collect_seq(a.iter().map(SpecOrder)),
61 204249 : other => other.serialize(s),
62 : }
63 295886 : }
64 : }
65 :
66 : /// Serialize a response or notification payload in egress key order
67 : /// (serializing a `Value` cannot fail).
68 8878 : pub fn ordered_vec(v: &serde_json::Value) -> Vec<u8> {
69 8878 : serde_json::to_vec(&SpecOrder(v)).unwrap_or_default()
70 8878 : }
71 :
72 : /// TS 104 175 Table 5.2.6.6.5-1: the RegistrationInfo members that name
73 : /// the Attributes a registration scopes. `attributeNames` replaces the
74 : /// deprecated pair, which is "only used if attributeNames is not present".
75 : pub const REGISTRATION_ATTRIBUTE_MEMBERS: [&str; 3] =
76 : ["attributeNames", "propertyNames", "relationshipNames"];
77 :
78 : /// The Attribute names one RegistrationInfo scopes (TS 104 175 9.4.2):
79 : /// `attributeNames` when present, else `propertyNames` and
80 : /// `relationshipNames` together; `None` when it names none, so the
81 : /// registration scopes every Attribute of its entities. The one reading
82 : /// every matcher shares: a matcher that reads only the old pair treats an
83 : /// `attributeNames`-only registration as unscoped and forwards it every
84 : /// query.
85 7800 : pub fn registration_attribute_names(info: &serde_json::Value) -> Option<Vec<&str>> {
86 5376 : fn strings(list: &[serde_json::Value]) -> Vec<&str> {
87 5376 : list.iter().filter_map(serde_json::Value::as_str).collect()
88 5376 : }
89 23364 : let names = |key: &str| info.get(key).and_then(serde_json::Value::as_array);
90 7800 : if let Some(attributes) = names("attributeNames") {
91 18 : return Some(strings(attributes));
92 7782 : }
93 7782 : let (properties, relationships) = (names("propertyNames"), names("relationshipNames"));
94 7782 : if properties.is_none() && relationships.is_none() {
95 2482 : return None;
96 5300 : }
97 : Some(
98 5300 : properties
99 5300 : .into_iter()
100 5300 : .chain(relationships)
101 5358 : .flat_map(|list| strings(list))
102 5300 : .collect(),
103 : )
104 7800 : }
105 :
106 : /// 5.2.4 Entity, Table 5.2.4-1, with the common members of Table 5.2.2-1:
107 : /// the members of an Entity document that are not Attributes. Every other
108 : /// member is a Property or a Relationship (`location` and the two other
109 : /// default GeoProperties included), so this list is what every layer that
110 : /// has to tell an attribute from an entity member reads — the query
111 : /// projection, the notification diff, the temporal split, the outbox event.
112 : /// A layer with its own copy is a layer that will disagree with the others
113 : /// about what an attribute is.
114 : ///
115 : /// `@context` is in the list because a stored or rendered document carries
116 : /// it and it is not an Attribute either. It is the one member Table 5.2.4-1
117 : /// does not name, which is why [`is_meta`] — the Entity's OWN members, the
118 : /// question 5.2.4 asks — leaves it out.
119 : pub const ENTITY_META_KEYS: &[&str] = &[
120 : "id",
121 : "type",
122 : "scope",
123 : "createdAt",
124 : "modifiedAt",
125 : "deletedAt",
126 : "expiresAt",
127 : "@context",
128 : ];
129 :
130 : /// The members of an Entity that are not Attributes — `id`, `type`,
131 : /// `scope`, `expiresAt`, `createdAt`, `modifiedAt`, `deletedAt`. See
132 : /// [`ENTITY_META_KEYS`] for the document-level list this narrows.
133 351290 : pub fn is_meta(k: &str) -> bool {
134 351290 : k != "@context" && ENTITY_META_KEYS.contains(&k)
135 351290 : }
136 :
137 : /// Canonical lexicographic comparison key for a 4.6.3 DateTime: the trailing
138 : /// `Z` dropped and the optional seconds fraction (`.` or the request-side `,`
139 : /// separator) zero-padded to six digits, so string order equals temporal
140 : /// order across spellings of the same instant. Non-DateTime input is
141 : /// returned as-is (callers validated at write/parse time).
142 334863 : pub fn dt_key(s: &str) -> String {
143 334863 : let Some(body) = s.strip_suffix('Z') else {
144 204 : return s.to_owned();
145 : };
146 334659 : if !body.is_char_boundary(19) {
147 6 : return s.to_owned();
148 334653 : }
149 334653 : let (base, frac) = body.split_at(19);
150 334653 : let digits = frac
151 334653 : .strip_prefix('.')
152 334653 : .or_else(|| frac.strip_prefix(','))
153 334653 : .unwrap_or("");
154 334653 : format!("{base}.{digits:0<6}")
155 334863 : }
156 :
157 : /// One ISO 8601 duration — `P[nY][nM][nW][nD][T[nH][nM][nS]]` — read into
158 : /// its components. Three NGSI-LD members carry this syntax and weigh it
159 : /// differently: a Context Source registration's refresh rate (5.2.9) and an
160 : /// EntityMap's `entityMapLifetime` (Table 6.4.3.2-1) want a span in seconds,
161 : /// where a month is a nominal thirty days, while a temporal aggregation
162 : /// period (4.5.19) keeps months as calendar months because that is what its
163 : /// buckets are cut on. The scan is one function; the weighing belongs to
164 : /// the caller.
165 : #[derive(Debug, Clone, Copy, Default, PartialEq)]
166 : pub struct IsoDuration {
167 : /// `nY`.
168 : pub years: f64,
169 : /// `nM` before the `T` — calendar months.
170 : pub months: f64,
171 : /// `nW`.
172 : pub weeks: f64,
173 : /// `nD`.
174 : pub days: f64,
175 : /// `nH`.
176 : pub hours: f64,
177 : /// `nM` after the `T`.
178 : pub minutes: f64,
179 : /// `nS`.
180 : pub seconds: f64,
181 : /// Every component present is a plain digit run within `i64`: no
182 : /// fraction, no magnitude a whole-second span could not hold.
183 : pub whole: bool,
184 : /// No component at all — a bare `P`.
185 : pub empty: bool,
186 : }
187 :
188 : /// Read the syntax, in the designator order ISO 8601 fixes. `None` for
189 : /// anything that is not it: a missing `P`, a component with no digit, a
190 : /// designator out of order, repeated or in the wrong half, a number that
191 : /// does not parse, digits with no designator to weigh them, or a `T` with
192 : /// no time component after it.
193 548 : pub fn parse_iso_duration(s: &str) -> Option<IsoDuration> {
194 : /// One half of the duration — the date designators or the time ones.
195 : /// `out` takes the values in `units` order; the answer is whether the
196 : /// half carried anything.
197 726 : fn scan(part: &str, units: &[char], out: &mut [f64], whole: &mut bool) -> Option<bool> {
198 726 : let mut p = part;
199 : // each designator is read at most once and in order: the search for
200 : // the next one starts after the last one matched
201 726 : let mut next = 0usize;
202 726 : let mut any = false;
203 1138 : while !p.is_empty() {
204 2402 : let i = p.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))?;
205 528 : let (num, rest) = p.split_at(i);
206 528 : let unit = rest.chars().next()?;
207 528 : let (slot, _) = units
208 528 : .iter()
209 528 : .enumerate()
210 528 : .skip(next)
211 1242 : .find(|(_, u)| **u == unit)?;
212 434 : next = slot.saturating_add(1);
213 444 : if !num.bytes().any(|b| b.is_ascii_digit()) {
214 18 : return None;
215 416 : }
216 416 : *whole &= num.parse::<i64>().is_ok();
217 : // 4.6.3 leaves the fraction separator open, here as everywhere
218 416 : let value: f64 = num.replace(',', ".").parse().ok()?;
219 414 : if !value.is_finite() {
220 2 : return None;
221 412 : }
222 412 : *out.get_mut(slot)? = value;
223 412 : any = true;
224 412 : p = &rest[unit.len_utf8()..];
225 : }
226 590 : Some(any)
227 726 : }
228 :
229 548 : let rest = s.strip_prefix('P')?;
230 456 : let (date, time) = match rest.split_once('T') {
231 270 : Some((d, t)) => (d, Some(t)),
232 186 : None => (rest, None),
233 : };
234 456 : let mut v = [0f64; 7];
235 456 : let mut whole = true;
236 456 : let date_any = scan(date, &['Y', 'M', 'W', 'D'], &mut v[..4], &mut whole)?;
237 356 : let time_any = match time {
238 86 : None => false,
239 270 : Some(t) => {
240 : // a `T` with nothing to designate is not a duration
241 270 : if !scan(t, &['H', 'M', 'S'], &mut v[4..], &mut whole)? {
242 20 : return None;
243 214 : }
244 214 : true
245 : }
246 : };
247 : Some(IsoDuration {
248 300 : years: v[0],
249 300 : months: v[1],
250 300 : weeks: v[2],
251 300 : days: v[3],
252 300 : hours: v[4],
253 300 : minutes: v[5],
254 300 : seconds: v[6],
255 300 : whole,
256 300 : empty: !date_any && !time_any,
257 : })
258 548 : }
259 :
260 : /// Attribute names in paths must be valid terms/IRIs (4.6.2) — 400 otherwise.
261 1261 : pub fn check_attr_name(attr: &str) -> Result<(), NgsiError> {
262 : // 4.6.2 supported names: no '@' (keyword territory), no parens/quotes/etc.
263 1261 : let ok = !attr.is_empty()
264 1254 : && attr
265 1254 : .chars()
266 7903 : .all(|c| c.is_ascii_alphanumeric() || "_:.#/%-+".contains(c))
267 1227 : && !has_dot_segment(attr);
268 1261 : if ok {
269 1124 : Ok(())
270 : } else {
271 137 : Err(NgsiError::BadRequestData(format!(
272 137 : "invalid attribute name {attr:?}"
273 137 : )))
274 : }
275 1261 : }
276 :
277 : /// A 4.6.2 name begins with a letter, so no valid Attribute name is a relative
278 : /// path dot-segment (RFC 3986 clause 5.2.4). The name is interpolated into the
279 : /// request URLs of forwarded operations, where a `.`/`..` segment addresses a
280 : /// different resource of the registration endpoint — `/entities/{id}/attrs/..`
281 : /// is that endpoint's Entity resource, and a URL parser resolves the segment
282 : /// before the request leaves this process. Percent triplets are folded once
283 : /// first, because the endpoint decodes the path it is given.
284 : ///
285 : /// The name a client sends is checked by [`check_attr_name`], but that is not
286 : /// the only form that reaches a path: 4.3.6.6 compacts the name again with a
287 : /// registered `@context`, which is client-supplied and may bind any term. The
288 : /// compacted form is held to this same rule before it is written into a
289 : /// forwarded URL.
290 1241 : pub fn has_dot_segment(attr: &str) -> bool {
291 1241 : attr.to_ascii_lowercase()
292 1241 : .replace("%2e", ".")
293 1241 : .replace("%2f", "/")
294 1241 : .split('/')
295 1345 : .any(|seg| seg == "." || seg == "..")
296 1241 : }
297 :
298 : #[cfg(test)]
299 : mod tests {
300 : use super::dt_key;
301 :
302 : #[test]
303 2 : fn attribute_names_come_first_and_the_old_pair_only_without_them() {
304 : use super::registration_attribute_names as names;
305 : use serde_json::json;
306 2 : let both = json!({"attributeNames": ["speed"], "propertyNames": ["brand"],
307 2 : "relationshipNames": ["owner"]});
308 2 : assert_eq!(names(&both), Some(vec!["speed"]));
309 2 : let old = json!({"propertyNames": ["brand"], "relationshipNames": ["owner"]});
310 2 : assert_eq!(names(&old), Some(vec!["brand", "owner"]));
311 2 : assert_eq!(
312 2 : names(&json!({"relationshipNames": ["owner"]})),
313 2 : Some(vec!["owner"])
314 : );
315 2 : assert_eq!(names(&json!({"entities": []})), None);
316 2 : assert_eq!(names(&json!({"attributeNames": []})), Some(vec![]));
317 2 : }
318 : #[test]
319 2 : fn meta_members_are_the_non_attribute_members_of_an_entity() {
320 14 : for k in [
321 2 : "id",
322 2 : "type",
323 2 : "scope",
324 2 : "createdAt",
325 2 : "modifiedAt",
326 2 : "deletedAt",
327 2 : "expiresAt",
328 2 : ] {
329 14 : assert!(super::is_meta(k), "{k}");
330 : }
331 20 : for k in [
332 2 : "",
333 2 : "v",
334 2 : "Type",
335 2 : "location",
336 2 : "observationSpace",
337 2 : "operationSpace",
338 2 : "speed",
339 2 : "@context",
340 2 : "observedAt",
341 2 : "datasetId",
342 2 : ] {
343 20 : assert!(!super::is_meta(k), "{k}");
344 : }
345 2 : }
346 :
347 : /// 4.6.3 DateTime: only a DateTime has a canonical key — anything else
348 : /// is returned unchanged, including a multi-byte string that ends in
349 : /// `Z` and is long enough to reach the seconds position in bytes.
350 : #[test]
351 2 : fn non_datetime_input_is_returned_unchanged() {
352 10 : for s in ["", "Z", "not-a-date", "ααααααααααZ", "urn:ngsi-ld:nullZ"] {
353 10 : assert_eq!(dt_key(s), s, "{s:?}");
354 : }
355 : // a real DateTime still normalizes to its comparison key
356 2 : assert_eq!(dt_key("2026-05-01T00:00:00Z"), "2026-05-01T00:00:00.000000");
357 2 : assert_eq!(
358 2 : dt_key("2026-05-01T00:00:00,5Z"),
359 : "2026-05-01T00:00:00.500000"
360 : );
361 2 : }
362 :
363 : /// One scan serves three weighings, so the syntax it accepts is the
364 : /// syntax all three accept: designators in ISO order, each at most once
365 : /// and in its own half, every component a number with a digit.
366 : #[test]
367 2 : fn a_duration_is_read_into_its_components() {
368 2 : let d = super::parse_iso_duration("P3Y6M4WT12H30M5.5S").expect("a duration");
369 2 : assert_eq!((d.years, d.months, d.weeks, d.days), (3.0, 6.0, 4.0, 0.0));
370 2 : assert_eq!((d.hours, d.minutes, d.seconds), (12.0, 30.0, 5.5));
371 2 : assert!(!d.whole, "a fractional component is not whole");
372 2 : assert!(!d.empty);
373 : // 4.6.3 leaves the fraction separator open
374 2 : assert_eq!(
375 2 : super::parse_iso_duration("PT0,5S").map(|d| d.seconds),
376 : Some(0.5)
377 : );
378 : // a bare P carries nothing to weigh, and is the only accepted shape
379 : // that carries nothing
380 2 : let bare = super::parse_iso_duration("P").expect("a bare P scans");
381 2 : assert!(bare.empty);
382 2 : assert!(bare.whole);
383 48 : for bad in [
384 2 : "",
385 2 : "PT",
386 2 : "P1DT", // a T with no time component after it
387 2 : "1Y",
388 2 : "P1",
389 2 : "PT1H1", // digits with no designator
390 2 : "P1X",
391 2 : "p1d",
392 2 : "P-1D",
393 2 : "P+1D",
394 2 : "P 1D",
395 2 : "PT1H ",
396 2 : " PT1H",
397 2 : "1PD",
398 2 : "P1H",
399 2 : "PT1D", // a designator in the wrong half
400 2 : "P1D2M",
401 2 : "PT1S1S", // out of order, and repeated
402 2 : "P,D",
403 2 : "P.D",
404 2 : "P..D",
405 2 : "P1.2.3D", // not a number
406 2 : "P\u{661}D",
407 2 : "P1D\u{0}",
408 2 : ] {
409 48 : assert_eq!(super::parse_iso_duration(bad), None, "{bad:?}");
410 : }
411 2 : }
412 :
413 : /// `whole` is what an EntityMap lifetime asks: the component is a plain
414 : /// digit run an `i64` of seconds can still hold.
415 : #[test]
416 2 : fn a_magnitude_past_i64_is_not_whole() {
417 6 : for s in ["PT99999999999999999999S", "PT1.5S", "PT1,5S"] {
418 6 : let d = super::parse_iso_duration(s).expect("it scans");
419 6 : assert!(!d.whole, "{s:?}");
420 : }
421 6 : for s in ["PT9223372036854775807S", "P0D", "PT0S"] {
422 6 : assert!(
423 6 : super::parse_iso_duration(s).expect("it scans").whole,
424 : "{s:?}"
425 : );
426 : }
427 2 : }
428 :
429 : /// The two views of the same list stay one list: `is_meta` answers
430 : /// Table 5.2.4-1's question — the Entity's OWN members — and
431 : /// `ENTITY_META_KEYS` answers the document's, which is the same set plus
432 : /// the `@context` a stored or rendered document carries.
433 : #[test]
434 2 : fn the_document_list_and_the_entity_list_agree() {
435 16 : for k in super::ENTITY_META_KEYS {
436 16 : assert_eq!(
437 16 : super::is_meta(k),
438 16 : *k != "@context",
439 : "{k} is in the document list"
440 : );
441 : }
442 2 : assert_eq!(
443 2 : super::ENTITY_META_KEYS.len(),
444 : 8,
445 : "seven Entity members plus @context"
446 : );
447 8 : for k in [
448 2 : "location",
449 2 : "speed",
450 2 : "https://uri.etsi.org/ngsi-ld/default-context/x",
451 2 : "",
452 2 : ] {
453 8 : assert!(!super::is_meta(k), "{k:?} is an Attribute, not a member");
454 8 : assert!(!super::ENTITY_META_KEYS.contains(&k), "{k:?}");
455 : }
456 2 : }
457 :
458 : /// Egress order: `id` then `type` lead every object, at every depth,
459 : /// arrays included; scalars pass through untouched.
460 : #[test]
461 2 : fn spec_order_leads_with_id_and_type_at_every_depth() {
462 : use serde_json::json;
463 2 : let v =
464 2 : json!({"z": 1, "type": "T", "a": [{"value": 2, "type": "Property"}], "id": "urn:x"});
465 2 : let out = String::from_utf8(super::ordered_vec(&v)).expect("utf-8");
466 2 : assert_eq!(
467 : out,
468 : r#"{"id":"urn:x","type":"T","a":[{"type":"Property","value":2}],"z":1}"#
469 : );
470 2 : assert_eq!(super::ordered_vec(&json!(null)), b"null");
471 2 : assert_eq!(super::ordered_vec(&json!({})), b"{}");
472 2 : }
473 :
474 : /// 4.6.2: a name is non-empty, from the allowed characters, and never a
475 : /// dot-segment however it is percent-spelled.
476 : #[test]
477 2 : fn attribute_names_and_dot_segments() {
478 : use super::{check_attr_name, has_dot_segment};
479 10 : for ok in ["speed", "ngsi-ld:x", "https://e.org/a#b", "a%20b", "a.b"] {
480 10 : assert!(check_attr_name(ok).is_ok(), "{ok:?}");
481 : }
482 18 : for bad in [
483 2 : "", "@id", "a b", "a(b)", "..", "a/./b", "%2E%2e", "a%2F..", "ü",
484 2 : ] {
485 18 : assert!(check_attr_name(bad).is_err(), "{bad:?}");
486 : }
487 2 : assert!(has_dot_segment("x/%2e"));
488 2 : assert!(!has_dot_segment("..a"));
489 2 : }
490 :
491 : /// A magnitude that overflows f64 is not a duration.
492 : #[test]
493 2 : fn a_duration_too_large_for_f64_is_refused() {
494 2 : let huge = format!("P{}Y", "9".repeat(400));
495 2 : assert!(super::parse_iso_duration(&huge).is_none());
496 2 : assert!(super::parse_iso_duration("P1Y").is_some());
497 2 : }
498 : }
|