Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Subscription matching (CIM 009 5.8.6) against one in-memory entity: the
3 : //! predicates the broker's notification path applies, usable without a
4 : //! broker — a gateway or an edge component can answer "would this change
5 : //! notify subscription X" with the broker's own semantics.
6 : //!
7 : //! Inputs are the stored (internal, expanded) forms: the subscription
8 : //! document as created (5.2.12, selector entity types expanded) and the
9 : //! entity document as the broker stores it (expanded attribute IRIs,
10 : //! `type` as an array). Matching is
11 : //! index-shaped in the broker (candidate lookup by (tenant, type) /
12 : //! (tenant, watched attribute)); every predicate here evaluates one
13 : //! candidate self-contained.
14 : //!
15 : #![doc = include_str!("../README.md")]
16 : // every surface calls into this crate on a request path, where a
17 : // panic is a remote DoS. Outside tests it may not unwrap, expect, panic,
18 : // index out of bounds or overflow.
19 : #![cfg_attr(
20 : not(test),
21 : deny(
22 : clippy::unwrap_used,
23 : clippy::expect_used,
24 : clippy::panic,
25 : clippy::indexing_slicing,
26 : clippy::arithmetic_side_effects
27 : )
28 : )]
29 : #![deny(missing_docs)]
30 :
31 : use antares_jsonld::Context;
32 : use antares_model::dt_key;
33 : use antares_ql::eval::EntityLookup;
34 : use antares_ql::geo::GeoQuery;
35 : use antares_ql::type_selection_matches;
36 : use serde_json::{Map, Value};
37 :
38 18866 : fn sub_str<'a>(sub: &'a Value, key: &str) -> Option<&'a str> {
39 18866 : sub.get(key).and_then(Value::as_str)
40 18866 : }
41 :
42 44 : fn now_iso() -> String {
43 44 : chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
44 44 : }
45 :
46 : /// Every predicate at once: active, entities selector (5.2.33), `q` /
47 : /// `scopeQ` / `geoQ` conditions, throttling window. `lookup` resolves 4.9
48 : /// linked-entity terms (`attr{…}`); `&|_| None` when no store is at hand.
49 100 : pub fn would_notify(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
50 100 : is_active(sub)
51 96 : && selector_match(sub, doc, ctx)
52 50 : && conditions_match(sub, doc, ctx, lookup)
53 46 : && !throttled(sub)
54 100 : }
55 :
56 : /// 5.8.1.4 / 5.2.12: `isActive` false or an `expiresAt` in the past means
57 : /// the subscription notifies nothing.
58 15970 : pub fn is_active(sub: &Value) -> bool {
59 15970 : if sub.get("isActive") == Some(&Value::Bool(false)) {
60 22 : return false;
61 15948 : }
62 : // 5.8.1.4 auto-expiry; dt_key so fraction spellings cannot misorder
63 : // around the boundary second (4.11)
64 15948 : !sub.get("expiresAt")
65 15948 : .and_then(Value::as_str)
66 15948 : .is_some_and(|e| dt_key(e) < dt_key(&now_iso()))
67 15970 : }
68 :
69 : /// entities selector (5.2.33) against an internal entity doc.
70 9592 : pub fn selector_match(sub: &Value, doc: &Value, ctx: &Context) -> bool {
71 9592 : let Some(sel) = sub.get("entities").and_then(Value::as_array) else {
72 18 : return true; // watchedAttributes-only subscription
73 : };
74 9574 : let types: Vec<&str> = doc
75 9574 : .get("type")
76 9574 : .and_then(Value::as_array)
77 9574 : .map(|a| a.iter().filter_map(Value::as_str).collect())
78 9574 : .unwrap_or_default();
79 9574 : let id = doc.get("id").and_then(Value::as_str).unwrap_or("");
80 9582 : sel.iter().any(|e| {
81 9582 : let t_ok = e.get("type").and_then(Value::as_str).is_none_or(|t| {
82 : // Table 5.2.33-1: "*" indicates "a request for all Entities" —
83 : // neither a term nor a 4.17 expression, but the absence of a type
84 : // predicate, exactly as `type=*` is on the query side.
85 9522 : if t == "*" {
86 18 : true
87 9504 : } else if t.contains(['|', ',', ';', '(']) {
88 28 : type_selection_matches(t, &types, ctx)
89 : } else {
90 9476 : types.contains(&t)
91 : }
92 9522 : });
93 : // Table 5.2.33-1: id is String or String[]; "id takes precedence
94 : // over idPattern" — a selector carrying id ignores its idPattern.
95 9582 : let id_ok = match e.get("id") {
96 9424 : None => true,
97 134 : Some(Value::String(i)) => i == id,
98 30 : Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).any(|i| i == id),
99 2 : Some(_) => false,
100 : };
101 9582 : let pat_ok = e.get("id").is_some()
102 9424 : || e.get("idPattern").and_then(Value::as_str).is_none_or(|p| {
103 48 : antares_ql::regex::compile(p).is_ok_and(|re| re.find(id).is_some())
104 48 : });
105 9582 : t_ok && id_ok && pat_ok
106 9582 : })
107 9592 : }
108 :
109 : /// A subscription's `geoQ` (Table 5.2.13-1) in the parameter shape the 4.10
110 : /// GeoQuery parser takes. The one reading of that table: every validator,
111 : /// matcher and forwarder in the broker turns a `geoQ` object into query
112 : /// parameters here, so `coordinates` is spelled the same way on all of them.
113 118 : pub fn geo_params(g: &Map<String, Value>) -> std::collections::HashMap<String, String> {
114 118 : let mut params: std::collections::HashMap<String, String> = Default::default();
115 354 : for k in ["georel", "geometry", "geoproperty"] {
116 354 : if let Some(s) = g.get(k).and_then(Value::as_str) {
117 288 : params.insert(k.into(), s.to_owned());
118 288 : }
119 : }
120 118 : if let Some(c) = g.get("coordinates") {
121 116 : params.insert(
122 116 : "coordinates".into(),
123 116 : match c {
124 8 : Value::String(s) => s.clone(),
125 108 : other => other.to_string(),
126 : },
127 : );
128 2 : }
129 118 : params
130 118 : }
131 :
132 : /// 5.8.6 notification matching: the subscription's q (4.9), scopeQ (4.19)
133 : /// and geoQ (4.10) conditions against an internal entity doc; all present
134 : /// conditions must hold.
135 9366 : pub fn conditions_match(sub: &Value, doc: &Value, ctx: &Context, lookup: EntityLookup) -> bool {
136 9366 : if let Some(q) = sub_str(sub, "q") {
137 : // a JSON string, read as written: no percent-decoding (5.2.12, 046_05)
138 : // parsed once per distinct q text, not once per event per candidate
139 94 : match antares_ql::regex::q_node(q) {
140 92 : Some(node) => {
141 : // Table 5.2.12-1 gives the Subscription the `expandValues`
142 : // and `jsonKeys` pair 4.9 gives the query, and this
143 : // condition IS that query: a term the first list names is
144 : // compared after JSON-LD type coercion against the @context
145 : // the Subscription was supplied with, less the names the
146 : // second declares uninterpretable.
147 : // ponytail: the coercion rebuilds the tree, so it costs one
148 : // clone per event for a Subscription that names the list and
149 : // nothing at all for one that does not; cache the coerced
150 : // tree beside the parsed one if a deployment measures it.
151 : let coerced;
152 92 : let node: &antares_ql::QNode = match antares_ql::eval::expansion_list(
153 92 : sub_str(sub, "expandValues"),
154 92 : sub_str(sub, "jsonKeys"),
155 92 : ) {
156 86 : None => &node,
157 6 : Some(list) => {
158 6 : coerced = antares_ql::eval::apply_expand_values(
159 6 : (*node).clone(),
160 6 : Some(&list),
161 6 : ctx,
162 : );
163 6 : &coerced
164 : }
165 : };
166 92 : if !antares_ql::eval::eval_q(node, doc, ctx, lookup) {
167 48 : return false;
168 44 : }
169 : }
170 2 : None => return false,
171 : }
172 9272 : }
173 9316 : if let Some(sq) = sub_str(sub, "scopeQ") {
174 4 : if !antares_ql::scope::scope_matches(sq, doc) {
175 2 : return false;
176 2 : }
177 9312 : }
178 9314 : if let Some(g) = sub.get("geoQ").and_then(Value::as_object) {
179 : // the geometry parse is shared per distinct geoQ member; the
180 : // serialization of the stored member is the key
181 12 : let key = serde_json::to_string(g).unwrap_or_default();
182 12 : let gq = antares_ql::regex::geo_query(&key, || {
183 4 : GeoQuery::from_params(&geo_params(g)).ok().flatten()
184 4 : });
185 12 : match gq {
186 10 : Some(gq) => {
187 10 : if !gq.matches(doc, ctx) {
188 2 : return false;
189 8 : }
190 : }
191 2 : None => return false,
192 : }
193 9302 : }
194 9310 : true
195 9366 : }
196 :
197 : /// 5.2.12 `throttling`: true while the last notification is younger than the
198 : /// throttling window, so no further notification is due yet.
199 9098 : pub fn throttled(sub: &Value) -> bool {
200 9098 : let Some(secs) = sub.get("throttling").and_then(Value::as_f64) else {
201 9073 : return false;
202 : };
203 25 : let Some(last) = sub
204 25 : .get("notification")
205 25 : .and_then(|n| n.get("lastNotification"))
206 25 : .and_then(Value::as_str)
207 : else {
208 9 : return false;
209 : };
210 16 : chrono::DateTime::parse_from_rfc3339(last).is_ok_and(|t| {
211 16 : chrono::Utc::now()
212 16 : .signed_duration_since(t.with_timezone(&chrono::Utc))
213 16 : .num_milliseconds()
214 16 : < (secs * 1000.0) as i64
215 16 : })
216 9098 : }
217 :
218 : #[cfg(test)]
219 : mod tests {
220 : use super::*;
221 : use serde_json::json;
222 : use std::sync::Arc;
223 :
224 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
225 :
226 : /// The core context once per process, with no loader behind it: the
227 : /// interpreter-run of these tests (Miri) must not pay for a cache and an
228 : /// HTTP client it never uses.
229 58 : fn ctx() -> Arc<Context> {
230 : static CORE: std::sync::OnceLock<Arc<Context>> = std::sync::OnceLock::new();
231 58 : CORE.get_or_init(|| Arc::new(antares_jsonld::core_context()))
232 58 : .clone()
233 58 : }
234 :
235 : /// A stored entity in its internal form: the broker's own expansion of
236 : /// the API payload (expanded IRIs, `type` as an array, instance arrays).
237 32 : fn expand(doc: Value) -> Value {
238 32 : antares_jsonld::expand_entity(
239 32 : doc.as_object().expect("object"),
240 32 : &ctx(),
241 32 : antares_jsonld::ExpandOpts::default(),
242 : )
243 32 : .expect("a valid entity")
244 32 : }
245 :
246 24 : fn vehicle(speed: f64, lon: f64) -> Value {
247 24 : expand(json!({
248 24 : "id": "urn:ngsi-ld:Vehicle:A1",
249 24 : "type": "Vehicle",
250 24 : "speed": {"type": "Property", "value": speed},
251 24 : "driver": {"type": "Relationship", "object": "urn:ngsi-ld:Person:P1"},
252 24 : "location": {"type": "GeoProperty",
253 24 : "value": {"type": "Point", "coordinates": [lon, 40.4]}}
254 : }))
255 24 : }
256 :
257 : /// A subscription as the broker stores it: entity types in the selector
258 : /// are already expanded (5.2.33), `q`/`geoQ` as sent.
259 28 : fn sub() -> Value {
260 28 : json!({
261 28 : "id": "urn:ngsi-ld:Subscription:1",
262 28 : "type": "Subscription",
263 28 : "entities": [{"type": format!("{DC}/Vehicle"), "idPattern": "^urn:ngsi-ld:Vehicle:.*"}],
264 28 : "q": "speed>25",
265 28 : "geoQ": {"georel": "near;maxDistance==2000", "geometry": "Point",
266 28 : "coordinates": "[-3.7,40.4]"},
267 28 : "notification": {"endpoint": {"uri": "http://x/n"}}
268 : })
269 28 : }
270 :
271 : /// Table 5.2.12-1 gives a Subscription the same `expandValues` and
272 : /// `jsonKeys` pair 4.9 gives a query, and the notification condition IS
273 : /// that query: a term whose Attribute `expandValues` names is compared
274 : /// after JSON-LD type coercion, and a name `jsonKeys` carries as well is
275 : /// left uninterpreted (6.4.3.2, the broker's precedence).
276 : #[test]
277 2 : fn clause_5_2_12_expand_values_coerces_the_condition_value() {
278 2 : let c = ctx();
279 2 : let none = |_: &str| None;
280 2 : let doc = expand(json!({
281 2 : "id": "urn:ngsi-ld:Vehicle:A1",
282 2 : "type": "Vehicle",
283 2 : "category": {"type": "Property", "value": format!("{DC}/Camping")}
284 : }));
285 2 : let mut s = sub();
286 2 : s.as_object_mut().expect("object").remove("geoQ");
287 2 : s["q"] = json!("category==\"Camping\"");
288 2 : assert!(
289 2 : !conditions_match(&s, &doc, &c, &none),
290 : "the literal cannot match the expanded value on its own"
291 : );
292 2 : s["expandValues"] = json!("category");
293 2 : assert!(
294 2 : conditions_match(&s, &doc, &c, &none),
295 : "expandValues coerces the term value"
296 : );
297 2 : s["jsonKeys"] = json!("category");
298 2 : assert!(
299 2 : !conditions_match(&s, &doc, &c, &none),
300 : "a name in both lists is left uninterpreted"
301 : );
302 2 : }
303 :
304 : /// The lookup of a test with no store: every linked Entity is absent.
305 2 : fn no_store(_: &str) -> Option<Value> {
306 2 : None
307 2 : }
308 :
309 : /// 5.8.6: every predicate holds → the change would notify.
310 : #[test]
311 2 : fn matching_change_notifies() {
312 2 : assert!(would_notify(
313 2 : &sub(),
314 2 : &vehicle(30.0, -3.7),
315 2 : &ctx(),
316 2 : &no_store
317 : ));
318 2 : }
319 :
320 : /// Each predicate alone refuses: q (4.9), geoQ (4.10), selector type,
321 : /// idPattern (5.2.33), `isActive`, `expiresAt` (5.8.1.4), throttling.
322 : #[test]
323 2 : fn each_failing_predicate_refuses() {
324 2 : let c = ctx();
325 2 : let none = |_: &str| None;
326 2 : assert!(!would_notify(&sub(), &vehicle(20.0, -3.7), &c, &none), "q");
327 2 : assert!(
328 2 : !would_notify(&sub(), &vehicle(30.0, -4.7), &c, &none),
329 : "geoQ"
330 : );
331 2 : let mut s = sub();
332 2 : s["entities"][0]["type"] = json!(format!("{DC}/Bicycle"));
333 2 : assert!(!would_notify(&s, &vehicle(30.0, -3.7), &c, &none), "type");
334 2 : let mut s = sub();
335 2 : s["entities"][0]["idPattern"] = json!("^urn:ngsi-ld:Bicycle:.*");
336 2 : assert!(
337 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
338 : "idPattern"
339 : );
340 2 : let mut s = sub();
341 2 : s["isActive"] = json!(false);
342 2 : assert!(
343 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
344 : "isActive"
345 : );
346 2 : let mut s = sub();
347 2 : s["expiresAt"] = json!("2000-01-01T00:00:00Z");
348 2 : assert!(
349 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
350 : "expiresAt"
351 : );
352 2 : let mut s = sub();
353 2 : s["throttling"] = json!(3600);
354 2 : s["notification"]["lastNotification"] = json!(now_iso());
355 2 : assert!(
356 2 : !would_notify(&s, &vehicle(30.0, -3.7), &c, &none),
357 : "throttling"
358 : );
359 2 : assert!(throttled(&s));
360 2 : s["notification"]["lastNotification"] = json!("2000-01-01T00:00:00Z");
361 2 : assert!(
362 2 : !throttled(&s),
363 : "an old lastNotification is outside the window"
364 : );
365 : // the window is in seconds: 10 s ago is inside 60 s and outside 5 s
366 2 : let ago = (chrono::Utc::now() - chrono::Duration::seconds(10))
367 2 : .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
368 2 : s["notification"]["lastNotification"] = json!(ago);
369 2 : s["throttling"] = json!(60);
370 2 : assert!(throttled(&s), "10 s ago, 60 s window");
371 2 : s["throttling"] = json!(5);
372 2 : assert!(!throttled(&s), "10 s ago, 5 s window");
373 2 : }
374 :
375 : /// 5.2.33 Table 5.2.33-1: `id` takes precedence over `idPattern`, and a
376 : /// selector without `type` matches every type.
377 : #[test]
378 2 : fn selector_id_precedence_and_typeless_selector() {
379 2 : let c = ctx();
380 2 : let doc = vehicle(30.0, -3.7);
381 2 : let s = json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:A1", "idPattern": "^nothing$"}]});
382 2 : assert!(selector_match(&s, &doc, &c));
383 2 : let s = json!({"entities": [{"id": ["urn:ngsi-ld:Vehicle:B2"], "type": format!("{DC}/Vehicle")}]});
384 2 : assert!(!selector_match(&s, &doc, &c));
385 2 : let s = json!({"watchedAttributes": ["speed"]});
386 2 : assert!(
387 2 : selector_match(&s, &doc, &c),
388 : "watchedAttributes-only subscription"
389 : );
390 2 : }
391 :
392 : /// The edges of the selector and the conditions (the `ngsild` coverage gate): a type
393 : /// expression of 4.17, an `id` that is neither a String nor a String[] (Table 5.2.33-1), a
394 : /// `q` or a `geoQ` the broker cannot read (it notifies nothing rather than everything), and
395 : /// `coordinates` written as a JSON array instead of the string Table 5.2.13-1 shows.
396 : #[test]
397 2 : fn selector_and_condition_edges() {
398 2 : let c = ctx();
399 2 : let none = |_: &str| None;
400 2 : let doc = vehicle(30.0, -3.7);
401 4 : let typed = |t: String| json!({"entities": [{"type": t}]});
402 2 : assert!(selector_match(
403 2 : &typed(format!("{DC}/Bicycle|{DC}/Vehicle")),
404 2 : &doc,
405 2 : &c
406 : ));
407 2 : assert!(!selector_match(
408 2 : &typed(format!("{DC}/Bicycle|{DC}/Car")),
409 2 : &doc,
410 2 : &c
411 2 : ));
412 2 : assert!(
413 2 : !selector_match(&json!({"entities": [{"id": 7}]}), &doc, &c),
414 : "id of no listed shape"
415 : );
416 :
417 2 : let mut s = sub();
418 2 : s["q"] = json!("(speed>25");
419 2 : assert!(antares_ql::parse_q("(speed>25").is_err());
420 2 : assert!(!conditions_match(&s, &doc, &c, &none), "an unreadable q");
421 2 : let mut s = sub();
422 2 : s["geoQ"]["geometry"] = json!("Nothing");
423 2 : assert!(!conditions_match(&s, &doc, &c, &none), "an unreadable geoQ");
424 :
425 2 : let g = json!({"georel": "near;maxDistance==2000", "geometry": "Point", "coordinates": [-3.7, 40.4]});
426 2 : assert_eq!(
427 2 : geo_params(g.as_object().expect("object"))["coordinates"],
428 : "[-3.7,40.4]"
429 : );
430 2 : let g = json!({"georel": "near;maxDistance==2000", "geometry": "Point"});
431 2 : assert!(!geo_params(g.as_object().expect("object")).contains_key("coordinates"));
432 2 : }
433 :
434 : /// 4.9 EXAMPLE 13/14: a linked-entity term (`driver{name}`) resolves the
435 : /// Relationship object through `lookup`; without a store it cannot match.
436 : #[test]
437 2 : fn linked_entity_term_uses_the_lookup() {
438 2 : let c = ctx();
439 2 : let mut s = sub();
440 2 : s["q"] = json!("driver{name}==\"Ann\"");
441 2 : let doc = vehicle(30.0, -3.7);
442 2 : let store = |uri: &str| {
443 2 : (uri == "urn:ngsi-ld:Person:P1").then(|| {
444 2 : expand(json!({"id": uri, "type": "Person",
445 2 : "name": {"type": "Property", "value": "Ann"}}))
446 2 : })
447 2 : };
448 2 : assert!(conditions_match(&s, &doc, &c, &store));
449 2 : assert!(
450 2 : !conditions_match(&s, &doc, &c, &no_store),
451 : "no store, no match"
452 : );
453 2 : }
454 :
455 : /// 4.19: `scopeQ` is a condition like `q`.
456 : #[test]
457 2 : fn scope_q_is_a_condition() {
458 2 : let c = ctx();
459 2 : let mut doc = vehicle(30.0, -3.7);
460 2 : doc["scope"] = json!(["/Madrid/Centre"]);
461 2 : let mut s = sub();
462 2 : s["scopeQ"] = json!("/Madrid/#");
463 2 : assert!(conditions_match(&s, &doc, &c, &no_store));
464 2 : s["scopeQ"] = json!("/Paris/#");
465 2 : assert!(!conditions_match(&s, &doc, &c, &no_store));
466 2 : }
467 :
468 : /// Table 5.2.12-1: a Subscription's `q` is a JSON string, not part of a
469 : /// URI, so it is read as written: `%20` is three characters (forge
470 : /// cd2ef2ba, 046_05).
471 : #[test]
472 2 : fn a_subscription_q_is_not_percent_decoded() {
473 2 : let c = ctx();
474 4 : let named = |name: &str| {
475 4 : expand(json!({
476 4 : "id": "urn:ngsi-ld:Vehicle:A1",
477 4 : "type": "Vehicle",
478 4 : "name": {"type": "Property", "value": name}
479 : }))
480 4 : };
481 2 : let mut s = sub();
482 2 : s.as_object_mut().expect("object").remove("geoQ");
483 2 : s["q"] = json!("name==\"Eiffel%20Tower\"");
484 2 : assert!(conditions_match(
485 2 : &s,
486 2 : &named("Eiffel%20Tower"),
487 2 : &c,
488 2 : &no_store
489 : ));
490 2 : assert!(!conditions_match(&s, &named("Eiffel Tower"), &c, &no_store));
491 2 : }
492 :
493 : /// Table 5.2.33-1 `type`: "A valid type selection string as per clause
494 : /// 4.17. To indicate a request for all Entities (with implied local
495 : /// scope), \"*\" is also allowed as a value." 5.2.33 scopes EntitySelector
496 : /// to what is "queried or subscribed to", so a Subscription carrying it
497 : /// selects every Entity Type — it is not a 4.17 expression and not a term
498 : /// to expand, but the absence of a type predicate.
499 : #[test]
500 2 : fn a_star_selector_type_selects_every_entity_type() {
501 2 : let sub = json!({"entities": [{"type": "*"}]});
502 6 : for doc in [
503 2 : json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
504 2 : json!({"id": "urn:ngsi-ld:Parking:1", "type": [format!("{DC}/Parking")]}),
505 2 : json!({"id": "urn:ngsi-ld:Odd:1", "type": ["urn:example:Odd"]}),
506 2 : ] {
507 6 : assert!(
508 6 : selector_match(&sub, &doc, &ctx()),
509 : "a \"*\" selector must match every type: {doc}"
510 : );
511 : }
512 : // "*" is the type predicate only — id and idPattern still narrow.
513 2 : let pinned = json!({"entities": [{"type": "*", "id": "urn:ngsi-ld:Vehicle:1"}]});
514 2 : assert!(selector_match(
515 2 : &pinned,
516 2 : &json!({"id": "urn:ngsi-ld:Vehicle:1", "type": [format!("{DC}/Vehicle")]}),
517 2 : &ctx()
518 : ));
519 2 : assert!(
520 2 : !selector_match(
521 2 : &pinned,
522 2 : &json!({"id": "urn:ngsi-ld:Vehicle:2", "type": [format!("{DC}/Vehicle")]}),
523 2 : &ctx()
524 2 : ),
525 : "a \"*\" type does not widen an id that was given"
526 : );
527 2 : }
528 : }
|