Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Compaction: internal expanded form → response document under the request
3 : //! @context. Never mutates its input (enforced by the &input signature).
4 :
5 : use crate::context::Context;
6 : use serde_json::{Map, Value};
7 :
8 : /// Entity-instance members whose values stay verbatim during compaction.
9 : const VERBATIM: &[&str] = &[
10 : "type",
11 : "value",
12 : "object",
13 : "datasetId",
14 : "observedAt",
15 : "unitCode",
16 : "lang",
17 : "languageMap",
18 : "json",
19 : "valueList",
20 : "objectList",
21 : "createdAt",
22 : "modifiedAt",
23 : "deletedAt",
24 : "instanceId",
25 : // 4.22: a transient attribute instance carries its own expiry, and 4.8
26 : // makes it the fifth Temporal Property beside the four already here.
27 : "expiresAt",
28 : // 5.8.6 showChanges: one previous-member per attribute type.
29 : // `previousJson` holds an arbitrary JSON value (4.5.20), which a
30 : // sub-attribute walk would rewrite key by key.
31 : "previousValue",
32 : "previousObject",
33 : "previousLanguageMap",
34 : "previousJson",
35 : ];
36 :
37 : /// Compact an internal expanded entity for output.
38 13942 : pub fn compact_entity(internal: &Value, ctx: &Context) -> Value {
39 13942 : let Some(obj) = internal.as_object() else {
40 4 : return internal.clone();
41 : };
42 13938 : let mut out = Map::new();
43 48224 : for (k, v) in obj {
44 48224 : match k.as_str() {
45 48224 : "id" => {
46 13906 : out.insert("id".into(), v.clone());
47 13906 : }
48 34318 : "type" => {
49 13904 : out.insert("type".into(), compact_types(v, ctx));
50 13904 : }
51 20414 : "scope" => {
52 870 : out.insert("scope".into(), unwrap_single(v.clone()));
53 870 : }
54 19544 : "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
55 4940 : out.insert(k.clone(), v.clone());
56 4940 : }
57 14604 : _ => {
58 14604 : let term = ctx.compact_iri(k);
59 14604 : out.insert(term, compact_attr_value(v, ctx));
60 14604 : }
61 : }
62 : }
63 13938 : Value::Object(out)
64 13942 : }
65 :
66 : /// Compact an expanded `@type` value: each IRI to its term, a one-element
67 : /// array unwrapped to a string.
68 14600 : pub fn compact_types(v: &Value, ctx: &Context) -> Value {
69 14600 : match v {
70 14588 : Value::Array(items) => unwrap_single(Value::Array(
71 14588 : items
72 14588 : .iter()
73 14596 : .map(|t| match t {
74 14594 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
75 2 : other => other.clone(),
76 14596 : })
77 14588 : .collect(),
78 : )),
79 10 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
80 2 : other => other.clone(),
81 : }
82 14600 : }
83 :
84 14738 : fn compact_attr_value(v: &Value, ctx: &Context) -> Value {
85 14738 : match v {
86 14736 : Value::Array(instances) => unwrap_single(Value::Array(
87 14756 : instances.iter().map(|i| compact_instance(i, ctx)).collect(),
88 : )),
89 2 : other => compact_instance(other, ctx),
90 : }
91 14738 : }
92 :
93 : /// Compact one attribute instance (public for temporal presentation, which
94 : /// keeps instance arrays un-unwrapped).
95 15922 : pub fn compact_instance(inst: &Value, ctx: &Context) -> Value {
96 15922 : let Some(obj) = inst.as_object() else {
97 10 : return inst.clone();
98 : };
99 15912 : let mut out = Map::new();
100 47384 : for (k, v) in obj {
101 47384 : if k == "vocab" || k == "previousVocab" {
102 : // vocab values compact back to terms
103 28 : let compacted = match v {
104 26 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
105 2 : Value::Array(a) => Value::Array(
106 2 : a.iter()
107 4 : .map(|s| match s {
108 4 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
109 0 : o => o.clone(),
110 4 : })
111 2 : .collect(),
112 : ),
113 0 : o => o.clone(),
114 : };
115 28 : out.insert(k.clone(), compacted);
116 47356 : } else if k == "objectType" {
117 2 : out.insert("objectType".into(), compact_types(v, ctx));
118 47354 : } else if k == "entityTypeSealed" {
119 4 : // 4.5.2.2 / annex B: @vocab-coerced — compacts back to a term
120 4 : // exactly like a type name; entityIdSealed needs no arm (a
121 4 : // plain string passes through the default member handling)
122 4 : out.insert("entityTypeSealed".into(), compact_types(v, ctx));
123 47350 : } else if k == "objectList" || k == "previousObjectList" {
124 : // 4.5.22.2: the normalized objectList is an ordered array of
125 : // JSON objects each "containing a single Attribute with a key
126 : // called "object"" — the internal form stores bare URIs.
127 12 : let wrapped = match v {
128 10 : Value::Array(a) => Value::Array(
129 10 : a.iter()
130 18 : .map(|it| match it {
131 16 : Value::String(uri) => serde_json::json!({ "object": uri }),
132 2 : other => other.clone(),
133 18 : })
134 10 : .collect(),
135 : ),
136 2 : other => other.clone(),
137 : };
138 12 : out.insert(k.clone(), wrapped);
139 47338 : } else if VERBATIM.contains(&k.as_str()) {
140 47204 : out.insert(k.clone(), v.clone());
141 47204 : } else {
142 134 : // sub-attribute
143 134 : out.insert(ctx.compact_iri(k), compact_attr_value(v, ctx));
144 134 : }
145 : }
146 15912 : Value::Object(out)
147 15922 : }
148 :
149 : /// Shallow compaction for simplified (keyValues) docs: rename top-level keys
150 : /// and compact type values, leave attribute VALUES verbatim (they are plain
151 : /// JSON — recursing would mangle e.g. single-ring polygons).
152 12 : pub fn compact_entity_shallow(internal: &Value, ctx: &Context) -> Value {
153 12 : let Some(obj) = internal.as_object() else {
154 2 : return internal.clone();
155 : };
156 10 : let mut out = Map::new();
157 34 : for (k, v) in obj {
158 34 : match k.as_str() {
159 34 : "id" | "scope" | "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
160 8 : out.insert(k.clone(), v.clone());
161 8 : }
162 26 : "type" => {
163 8 : out.insert("type".into(), compact_types(v, ctx));
164 8 : }
165 18 : _ => {
166 18 : out.insert(ctx.compact_iri(k), compact_simplified_value(v, ctx));
167 18 : }
168 : }
169 : }
170 10 : Value::Object(out)
171 12 : }
172 :
173 : /// 4.5.4 Simplified Representation: the VocabProperty form is the single-key
174 : /// object {"vocab": …} (Example 6) whose IRI(s) compact back to terms, exactly
175 : /// as on the normalized path; multi-instance attributes are the {"dataset":
176 : /// `{<datasetId>|"@none": <simplified>}}` map (Example 2), compacted per
177 : /// instance. All other simplified values are plain JSON and stay verbatim.
178 : ///
179 : /// Ceiling, and it belongs to the representation rather than to this
180 : /// function: a Property whose VALUE is itself the single-key object
181 : /// `{"vocab": …}` or `{"dataset": …}` is indistinguishable here from the
182 : /// wrapper 4.5.4 gives those two forms, because the simplified document no
183 : /// longer carries the attribute type that would tell them apart. The
184 : /// normalized representation is lossless and is what a consumer that needs
185 : /// the distinction asks for. A JsonProperty is NOT affected: 4.5.4 wraps its
186 : /// payload as `{"json": …}`, so an arbitrary JSON value never reaches this
187 : /// test unwrapped.
188 22 : fn compact_simplified_value(v: &Value, ctx: &Context) -> Value {
189 22 : let Some(o) = v.as_object() else {
190 6 : return v.clone();
191 : };
192 16 : if o.len() == 1 {
193 12 : if let Some(vocab) = o.get("vocab") {
194 4 : let compacted = match vocab {
195 4 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
196 0 : Value::Array(a) => Value::Array(
197 0 : a.iter()
198 0 : .map(|s| match s {
199 0 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
200 0 : other => other.clone(),
201 0 : })
202 0 : .collect(),
203 : ),
204 0 : other => other.clone(),
205 : };
206 4 : return serde_json::json!({ "vocab": compacted });
207 8 : }
208 8 : if let Some(Value::Object(m)) = o.get("dataset") {
209 2 : let per_instance: Map<String, Value> = m
210 2 : .iter()
211 4 : .map(|(k, iv)| (k.clone(), compact_simplified_value(iv, ctx)))
212 2 : .collect();
213 2 : return serde_json::json!({ "dataset": per_instance });
214 6 : }
215 4 : }
216 10 : v.clone()
217 22 : }
218 :
219 30194 : fn unwrap_single(v: Value) -> Value {
220 30194 : match v {
221 30194 : Value::Array(mut items) if items.len() == 1 => items.remove(0),
222 38 : other => other,
223 : }
224 30194 : }
225 :
226 : #[cfg(test)]
227 : mod tests {
228 : use super::*;
229 : use crate::expand::{expand_entity, ExpandOpts};
230 : use crate::loader::Loader;
231 : use serde_json::json;
232 :
233 : #[test]
234 2 : fn round_trip_under_core_context() {
235 2 : let input = json!({
236 2 : "id": "urn:ngsi-ld:Building:1",
237 2 : "type": "Building",
238 2 : "name": {"type": "Property", "value": "Eiffel Tower"},
239 2 : "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2.29, 48.85]}}
240 : });
241 2 : let ctx = Loader::new().core();
242 2 : let expanded =
243 2 : expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
244 2 : let compacted = compact_entity(&expanded, &ctx);
245 2 : assert_eq!(compacted, input);
246 : // input untouched by construction (&input); expanded untouched too
247 2 : assert_eq!(expanded["id"], "urn:ngsi-ld:Building:1");
248 2 : }
249 :
250 : /// 4.5.4 Example 6: simplified VocabProperty vocab IRIs compact to terms;
251 : /// dataset-map instances compact per instance (Example 9).
252 : #[test]
253 2 : fn simplified_vocab_compacts_to_term() {
254 2 : let ctx = Loader::new().core();
255 2 : let doc = json!({
256 2 : "id": "urn:ngsi-ld:V:1",
257 2 : "type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle",
258 2 : "https://uri.etsi.org/ngsi-ld/default-context/category":
259 2 : {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"},
260 2 : "https://uri.etsi.org/ngsi-ld/default-context/mixed":
261 2 : {"dataset": {"@none": {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/rental"}, "urn:ngsi-ld:Dataset:1": 7}}
262 : });
263 2 : let out = compact_entity_shallow(&doc, &ctx);
264 2 : assert_eq!(out["category"], json!({"vocab": "non-commercial"}));
265 2 : assert_eq!(
266 2 : out["mixed"],
267 2 : json!({"dataset": {"@none": {"vocab": "rental"}, "urn:ngsi-ld:Dataset:1": 7}})
268 : );
269 2 : }
270 :
271 : /// 4.5.22.2: normalized objectList round-trips — {"object": URI} entries
272 : /// in, bare URIs internally, {"object": URI} entries out.
273 : #[test]
274 2 : fn object_list_normalized_round_trip() {
275 2 : let input = json!({
276 2 : "id": "urn:ngsi-ld:B:1",
277 2 : "type": "T",
278 2 : "route": {"type": "ListRelationship",
279 2 : "objectList": [{"object": "urn:ngsi-ld:R:1"}, "urn:ngsi-ld:R:2"]}
280 : });
281 2 : let ctx = Loader::new().core();
282 2 : let expanded =
283 2 : expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
284 2 : let route = &expanded["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
285 2 : assert_eq!(
286 2 : route["objectList"],
287 2 : json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
288 : );
289 2 : let compacted = compact_entity(&expanded, &ctx);
290 2 : assert_eq!(
291 2 : compacted["route"]["objectList"],
292 2 : json!([{"object": "urn:ngsi-ld:R:1"}, {"object": "urn:ngsi-ld:R:2"}])
293 : );
294 2 : }
295 :
296 16 : fn ctx_of(v: Value) -> crate::context::Context {
297 16 : let mut c = crate::context::Context::default();
298 16 : c.merge_object(v.as_object().unwrap()).unwrap();
299 16 : c.freeze();
300 16 : c
301 16 : }
302 :
303 : // ---- compact_entity -----------------------------------------------
304 :
305 : /// Nothing is silently dropped: an attribute IRI the context has no term
306 : /// for keeps its full IRI, and the member count is preserved.
307 : #[test]
308 2 : fn unmapped_attributes_survive_compaction() {
309 2 : let ctx = ctx_of(json!({"name": "https://example.org/name"}));
310 2 : let internal = json!({
311 2 : "id": "urn:ngsi-ld:B:1",
312 2 : "type": ["https://example.org/Building"],
313 2 : "https://example.org/name": [{"type": "Property", "value": "x"}],
314 2 : "https://elsewhere.example/unmapped": [{"type": "Property", "value": 1}]
315 : });
316 2 : let out = compact_entity(&internal, &ctx);
317 2 : let o = out.as_object().unwrap();
318 2 : assert_eq!(o.len(), 4, "no member may vanish: {out}");
319 2 : assert!(o.contains_key("name"));
320 2 : assert!(o.contains_key("https://elsewhere.example/unmapped"));
321 2 : assert!(!o.contains_key("https://example.org/name"));
322 : // single-element type array is unwrapped to a scalar
323 2 : assert_eq!(out["type"], json!("https://example.org/Building"));
324 2 : }
325 :
326 : /// Reserved entity members must never be overwritten by an attribute that
327 : /// would compact to the same name: the round-trip guard in compaction
328 : /// falls back to prefix compaction, so the attribute keeps a key of its
329 : /// own and the system members keep their values.
330 : #[test]
331 2 : fn reserved_members_are_not_clobbered_by_attributes() {
332 2 : let ctx = Loader::new().core();
333 2 : let vocab = "https://uri.etsi.org/ngsi-ld/default-context/";
334 2 : let mut internal = Map::new();
335 2 : internal.insert("id".into(), json!("urn:ngsi-ld:B:1"));
336 2 : internal.insert("type".into(), json!(format!("{vocab}Building")));
337 6 : for shadow in ["type", "id", "value"] {
338 6 : internal.insert(
339 6 : format!("{vocab}{shadow}"),
340 6 : json!([{"type": "Property", "value": "shadow"}]),
341 6 : );
342 6 : }
343 2 : let out = compact_entity(&Value::Object(internal), &ctx);
344 2 : assert_eq!(out["id"], "urn:ngsi-ld:B:1");
345 2 : assert_eq!(out["type"], "Building");
346 2 : assert_eq!(out.as_object().unwrap().len(), 5, "no member lost: {out}");
347 : // negative: not one of the three shadows may be rendered under its
348 : // bare reserved name — each keeps a key that expands back to itself.
349 6 : for shadow in ["type", "id", "value"] {
350 6 : let key = ctx.compact_iri(&format!("{vocab}{shadow}"));
351 6 : assert_ne!(key, shadow, "{shadow} was clobbered: {out}");
352 6 : assert_eq!(ctx.expand_key(&key), format!("{vocab}{shadow}"));
353 6 : assert_eq!(
354 6 : out[&key]["value"], "shadow",
355 : "{shadow} lost its value: {out}"
356 : );
357 : }
358 2 : }
359 :
360 : /// Non-object input is returned untouched; timestamps pass through
361 : /// verbatim; a single-element scope array is unwrapped.
362 : #[test]
363 2 : fn entity_edge_shapes() {
364 2 : let ctx = Loader::new().core();
365 2 : assert_eq!(
366 2 : compact_entity(&json!("not an object"), &ctx),
367 2 : json!("not an object")
368 : );
369 2 : assert_eq!(compact_entity(&json!([]), &ctx), json!([]));
370 2 : assert_eq!(compact_entity(&json!({}), &ctx), json!({}));
371 2 : let out = compact_entity(
372 2 : &json!({"id": "urn:x", "scope": ["/a"], "createdAt": "2026-01-01T00:00:00Z",
373 2 : "modifiedAt": "2026-01-02T00:00:00Z", "deletedAt": "2026-01-03T00:00:00Z",
374 2 : "expiresAt": "2026-01-04T00:00:00Z"}),
375 2 : &ctx,
376 : );
377 2 : assert_eq!(out["scope"], json!("/a"));
378 2 : assert_eq!(out["createdAt"], "2026-01-01T00:00:00Z");
379 2 : assert_eq!(out["expiresAt"], "2026-01-04T00:00:00Z");
380 2 : let out = compact_entity(&json!({"scope": ["/a", "/b"]}), &ctx);
381 2 : assert_eq!(out["scope"], json!(["/a", "/b"]));
382 2 : }
383 :
384 : /// 4.8 names five Temporal Properties — observedAt, createdAt,
385 : /// modifiedAt, deletedAt and expiresAt — and 4.22 puts `expiresAt` on an
386 : /// attribute instance, not only on the Entity. All five are reserved
387 : /// members whose value is a DateTime string, so none of them is a
388 : /// sub-attribute to be renamed under the request @context.
389 : ///
390 : /// 5.8.6 adds the showChanges previous-members, one per attribute type;
391 : /// `previousJson` carries an arbitrary JSON object (4.5.20), which a
392 : /// sub-attribute walk rewrites key by key.
393 : #[test]
394 2 : fn reserved_instance_members_are_not_compacted_as_sub_attributes() {
395 : // a request @context that renames both, which is what a member
396 : // treated as a sub-attribute name would obey
397 2 : let ctx = ctx_of(json!({
398 2 : "exp": "expiresAt",
399 2 : "pj": "previousJson",
400 2 : "inner": "https://example.org/inner"
401 : }));
402 : // a JSON value whose own keys happen to be IRIs the @context knows:
403 : // a sub-attribute walk rewrites them, which is data corruption, not
404 : // compaction — 4.5.20 keeps a JsonProperty value uninterpreted.
405 2 : let payload = json!({
406 2 : "https://example.org/inner": 1,
407 2 : "nested": {"https://example.org/inner": 2}
408 : });
409 2 : let inst = json!({
410 2 : "type": "JsonProperty",
411 2 : "json": payload,
412 2 : "previousJson": payload,
413 2 : "expiresAt": "2026-01-04T00:00:00Z"
414 : });
415 2 : let out = compact_instance(&inst, &ctx);
416 2 : assert_eq!(
417 2 : out["expiresAt"], "2026-01-04T00:00:00Z",
418 : "expiresAt renamed or rewritten: {out}"
419 : );
420 2 : assert_eq!(out["previousJson"], payload, "previousJson mangled: {out}");
421 : // and the JsonProperty's own value was already safe
422 2 : assert_eq!(out["json"], payload);
423 2 : }
424 :
425 : // ---- compact_types ------------------------------------------------
426 :
427 : #[test]
428 2 : fn compact_types_shapes() {
429 2 : let ctx = ctx_of(json!({"B": "https://example.org/B"}));
430 2 : assert_eq!(
431 2 : compact_types(&json!("https://example.org/B"), &ctx),
432 2 : json!("B")
433 : );
434 2 : assert_eq!(
435 2 : compact_types(&json!(["https://example.org/B"]), &ctx),
436 2 : json!("B")
437 : );
438 2 : assert_eq!(
439 2 : compact_types(
440 2 : &json!(["https://example.org/B", "https://example.org/C"]),
441 2 : &ctx
442 : ),
443 2 : json!(["B", "https://example.org/C"])
444 : );
445 : // an empty list stays a list, non-strings pass through unchanged
446 2 : assert_eq!(compact_types(&json!([]), &ctx), json!([]));
447 2 : assert_eq!(compact_types(&json!([42]), &ctx), json!(42));
448 2 : assert_eq!(compact_types(&json!(null), &ctx), json!(null));
449 2 : }
450 :
451 : // ---- compact_instance ---------------------------------------------
452 :
453 : /// Property values are opaque JSON: their inner keys must NOT be compacted
454 : /// even when they look like IRIs the context knows.
455 : #[test]
456 2 : fn property_values_stay_verbatim() {
457 2 : let ctx = ctx_of(json!({"name": "https://example.org/name"}));
458 2 : let inst = json!({
459 2 : "type": "Property",
460 2 : "value": {"https://example.org/name": "not an attribute", "nested": [1, 2]},
461 2 : "unitCode": "CEL",
462 2 : "observedAt": "2026-01-01T00:00:00Z",
463 2 : "https://example.org/name": [{"type": "Property", "value": "sub"}]
464 : });
465 2 : let out = compact_instance(&inst, &ctx);
466 2 : assert_eq!(out["value"], inst["value"], "value must not be rewritten");
467 2 : assert_eq!(out["unitCode"], "CEL");
468 : // the sub-attribute IRI, in contrast, does compact to its term
469 2 : assert_eq!(out["name"], json!({"type": "Property", "value": "sub"}));
470 2 : assert!(out
471 2 : .as_object()
472 2 : .unwrap()
473 2 : .get("https://example.org/name")
474 2 : .is_none());
475 2 : }
476 :
477 : /// vocab / previousVocab / objectType / entityTypeSealed all carry IRIs
478 : /// that compact back to terms; a non-object instance is returned as-is.
479 : #[test]
480 2 : fn vocab_and_type_members_compact() {
481 2 : let ctx = ctx_of(json!({"cat": "https://example.org/cat",
482 2 : "T": "https://example.org/T"}));
483 2 : let out = compact_instance(
484 2 : &json!({"type": "VocabProperty",
485 2 : "vocab": ["https://example.org/cat", "https://elsewhere.example/x"],
486 2 : "previousVocab": "https://example.org/cat",
487 2 : "objectType": ["https://example.org/T"],
488 2 : "entityTypeSealed": ["https://example.org/T"]}),
489 2 : &ctx,
490 : );
491 2 : assert_eq!(out["vocab"], json!(["cat", "https://elsewhere.example/x"]));
492 2 : assert_eq!(out["previousVocab"], json!("cat"));
493 2 : assert_eq!(out["objectType"], json!("T"));
494 2 : assert_eq!(out["entityTypeSealed"], json!("T"));
495 2 : assert_eq!(compact_instance(&json!(7), &ctx), json!(7));
496 2 : assert_eq!(compact_instance(&json!("s"), &ctx), json!("s"));
497 2 : }
498 :
499 : /// 4.5.22.2: bare URIs are wrapped, entries already in object form are not
500 : /// wrapped twice, and a non-array objectList passes through.
501 : #[test]
502 2 : fn object_list_wrapping_edges() {
503 2 : let ctx = Loader::new().core();
504 2 : let out = compact_instance(
505 2 : &json!({"type": "ListRelationship",
506 2 : "objectList": ["urn:a", {"object": "urn:b"}],
507 2 : "previousObjectList": ["urn:c"]}),
508 2 : &ctx,
509 : );
510 2 : assert_eq!(
511 2 : out["objectList"],
512 2 : json!([{"object": "urn:a"}, {"object": "urn:b"}])
513 : );
514 2 : assert_eq!(out["previousObjectList"], json!([{"object": "urn:c"}]));
515 2 : let out = compact_instance(&json!({"objectList": "urn:a"}), &ctx);
516 2 : assert_eq!(out["objectList"], json!("urn:a"));
517 2 : }
518 :
519 : /// Multi-instance attributes keep their array; a single instance is
520 : /// unwrapped; an empty instance array stays empty.
521 : #[test]
522 2 : fn attribute_instance_arrays() {
523 2 : let ctx = ctx_of(json!({"a": "https://example.org/a"}));
524 2 : let out = compact_entity(
525 2 : &json!({"https://example.org/a": [
526 2 : {"type": "Property", "value": 1, "datasetId": "urn:d:1"},
527 2 : {"type": "Property", "value": 2}]}),
528 2 : &ctx,
529 : );
530 2 : assert_eq!(out["a"].as_array().unwrap().len(), 2);
531 2 : let out = compact_entity(&json!({"https://example.org/a": []}), &ctx);
532 2 : assert_eq!(out["a"], json!([]));
533 2 : }
534 :
535 : // ---- compact_entity_shallow / compact_simplified_value ------------
536 :
537 : /// Simplified values are plain JSON and stay verbatim — a single-ring
538 : /// polygon must not be reshaped, and a two-member object is not mistaken
539 : /// for a VocabProperty.
540 : #[test]
541 2 : fn simplified_values_stay_verbatim() {
542 2 : let ctx = ctx_of(json!({"loc": "https://example.org/loc",
543 2 : "v": "https://example.org/v"}));
544 2 : let out = compact_entity_shallow(
545 2 : &json!({
546 2 : "id": "urn:x", "type": "https://example.org/T",
547 2 : "https://example.org/loc": {"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]},
548 2 : "https://example.org/v": {"vocab": "https://example.org/loc", "extra": 1}
549 2 : }),
550 2 : &ctx,
551 : );
552 2 : assert_eq!(
553 2 : out["loc"],
554 2 : json!({"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]})
555 : );
556 2 : assert_eq!(
557 2 : out["v"],
558 2 : json!({"vocab": "https://example.org/loc", "extra": 1})
559 : );
560 2 : assert_eq!(compact_entity_shallow(&json!("x"), &ctx), json!("x"));
561 2 : }
562 :
563 : /// A "dataset" member whose value is not an object is left alone.
564 : #[test]
565 2 : fn simplified_dataset_edges() {
566 2 : let ctx = Loader::new().core();
567 2 : let out = compact_entity_shallow(
568 2 : &json!({"https://uri.etsi.org/ngsi-ld/default-context/a": {"dataset": 5}}),
569 2 : &ctx,
570 : );
571 2 : assert_eq!(out["a"], json!({"dataset": 5}));
572 2 : }
573 :
574 : /// The mutual recursion compact_attr_value ↔ compact_instance (and the
575 : /// nested "dataset" recursion) is bounded by the JSON parser: serde_json
576 : /// refuses documents nested deeper than its own recursion limit, so an
577 : /// attacker cannot drive compaction to a stack overflow.
578 : #[test]
579 2 : fn nesting_depth_is_bounded_by_the_parser() {
580 4 : fn nest(levels: usize) -> String {
581 4 : let mut s = String::from("{\"type\":\"Property\",\"value\":1}");
582 440 : for _ in 0..levels {
583 440 : s = format!("{{\"https://example.org/s\":[{{\"type\":\"Property\",\"value\":1,\"https://example.org/s\":[{s}]}}]}}");
584 440 : }
585 4 : s
586 4 : }
587 2 : assert!(
588 2 : serde_json::from_str::<Value>(&nest(200)).is_err(),
589 : "the parser must refuse deeply nested documents"
590 : );
591 2 : let ctx = ctx_of(json!({"s": "https://example.org/s"}));
592 2 : let doc: Value = serde_json::from_str(&nest(20)).expect("within the parser limit");
593 2 : let out = compact_entity(&doc, &ctx);
594 2 : assert!(out.get("s").is_some());
595 2 : }
596 : }
|