Skip to main content

netget/client/bolt/
api.rs

1//! Typed selected Bolt messages. No query engine, graph store or opaque byte actions.
2use crate::server::bolt::{
3    messages as m,
4    packstream::{self, Value},
5};
6use anyhow::{bail, ensure, Context, Result};
7use serde_json::{json, Value as Json};
8pub const MAX_DEPTH: usize = 32;
9pub const MAX_NODES: usize = 65536;
10pub const MAX_RETAINED_BYTES: usize = 8 * 1024 * 1024;
11pub const MAX_PAGE_BYTES: usize = 4 * 1024 * 1024;
12pub const MAX_PAGE_RECORDS: usize = 500;
13pub const MAX_FIELDS: usize = 256;
14pub const MAX_TEXT: usize = 65536;
15pub const MAX_PASSWORD: usize = 16384;
16pub const MAX_EVENT_DEPTH: usize = MAX_DEPTH + 8;
17pub fn within_budget(value: &Json) -> bool {
18    crate::utils::json_budget::within_budget(value, MAX_RETAINED_BYTES, MAX_NODES, MAX_DEPTH)
19}
20pub enum Action {
21    Login {
22        username: Option<String>,
23        password: Option<String>,
24    },
25    Logoff,
26    Run {
27        query: String,
28        parameters: Value,
29        extra: Value,
30    },
31    Pull(usize),
32    Discard,
33    Begin(Value),
34    Commit,
35    Rollback,
36    Reset,
37    Disconnect,
38}
39fn text<'a>(value: &'a Json, key: &str, max: usize, required: bool) -> Result<Option<&'a str>> {
40    match value.get(key) {
41        None if !required => Ok(None),
42        Some(Json::String(s)) if !s.is_empty() && s.len() <= max => Ok(Some(s)),
43        _ => bail!("Bolt {key} must be a nonempty bounded string"),
44    }
45}
46fn extra(action: &Json) -> Result<Value> {
47    let mut extra = Vec::new();
48    if let Some(db) = text(action, "database", 256, false)? {
49        extra.push(("db".into(), Value::string(db)));
50    }
51    if let Some(mode) = action.get("mode") {
52        let mode = match mode.as_str() {
53            Some("read") => "r",
54            Some("write") => "w",
55            _ => bail!("Bolt mode must be read or write"),
56        };
57        extra.push(("mode".into(), Value::string(mode)));
58    }
59    if let Some(ms) = action.get("transaction_timeout_ms") {
60        let ms = ms
61            .as_i64()
62            .filter(|n| (1..=30000).contains(n))
63            .context("Bolt transaction_timeout_ms must be1..30000")?;
64        extra.push(("tx_timeout".into(), Value::Int(ms)));
65    }
66    if let Some(bookmarks) = action.get("bookmarks") {
67        let bookmarks = bookmarks
68            .as_array()
69            .filter(|v| v.len() <= 16)
70            .context("Bolt bookmarks must be a bounded list")?;
71        let values = bookmarks
72            .iter()
73            .map(|v| {
74                v.as_str()
75                    .filter(|s| !s.is_empty() && s.len() <= 4096)
76                    .map(Value::string)
77                    .context("Bolt bookmark must be bounded string")
78            })
79            .collect::<Result<Vec<_>>>()?;
80        extra.push(("bookmarks".into(), Value::List(values)));
81    }
82    Ok(Value::Map(extra))
83}
84fn parameter(value: &Json, depth: usize) -> Result<Value> {
85    ensure!(depth <= MAX_DEPTH + 1, "Bolt parameter wire depth limit");
86    if value.is_array() || value.is_object() {
87        ensure!(depth <= MAX_DEPTH, "Bolt parameter wire depth limit");
88    }
89    Ok(match value {
90        Json::Null => Value::Null,
91        Json::Bool(v) => Value::Bool(*v),
92        Json::Number(v) => {
93            if let Some(v) = v.as_i64() {
94                Value::Int(v)
95            } else if v.is_u64() {
96                bail!("Bolt parameters require signed64-bit integers")
97            } else {
98                Value::Float(
99                    v.as_f64()
100                        .filter(|v| v.is_finite())
101                        .context("Bolt finite number required")?,
102                )
103            }
104        }
105        Json::String(v) => {
106            ensure!(v.len() <= MAX_TEXT, "Bolt parameter string limit");
107            Value::string(v)
108        }
109        Json::Array(v) => {
110            ensure!(v.len() <= 10000, "Bolt parameter list limit");
111            Value::List(
112                v.iter()
113                    .map(|v| parameter(v, depth + 1))
114                    .collect::<Result<_>>()?,
115            )
116        }
117        Json::Object(v) => {
118            ensure!(v.len() <= 256, "Bolt parameter map limit");
119            Value::Map(
120                v.iter()
121                    .map(|(k, v)| {
122                        ensure!(k.len() <= 4096, "Bolt parameter key limit");
123                        Ok((k.clone(), parameter(v, depth + 1)?))
124                    })
125                    .collect::<Result<_>>()?,
126            )
127        }
128    })
129}
130pub fn action(value: &Json) -> Result<Action> {
131    ensure!(
132        within_budget(value),
133        "Bolt action depth/node/retained-content limit"
134    );
135    Ok(
136        match value
137            .get("type")
138            .and_then(Json::as_str)
139            .context("Bolt action type required")?
140        {
141            "bolt_login" => {
142                let username = text(value, "username", 256, false)?.map(str::to_owned);
143                let password = text(value, "password", MAX_PASSWORD, false)?.map(str::to_owned);
144                ensure!(
145                    username.is_some() == password.is_some(),
146                    "Bolt basic login requires both username and password; omit both for none"
147                );
148                Action::Login { username, password }
149            }
150            "bolt_logoff" => Action::Logoff,
151            "bolt_run" => {
152                let query = text(value, "query", MAX_TEXT, true)?.unwrap().to_owned();
153                let empty = json!({});
154                let parameters = value.get("parameters").unwrap_or(&empty);
155                ensure!(parameters.is_object(), "Bolt parameters must be an object");
156                Action::Run {
157                    query,
158                    parameters: parameter(parameters, 2)?,
159                    extra: extra(value)?,
160                }
161            }
162            "bolt_pull" => Action::Pull(match value.get("n") {
163                None => 100,
164                Some(n) => n
165                    .as_u64()
166                    .filter(|n| (1..=MAX_PAGE_RECORDS as u64).contains(n))
167                    .context("Bolt pull n must be1..500")? as usize,
168            }),
169            "bolt_discard" => Action::Discard,
170            "bolt_begin" => Action::Begin(extra(value)?),
171            "bolt_commit" => Action::Commit,
172            "bolt_rollback" => Action::Rollback,
173            "bolt_reset" => Action::Reset,
174            "disconnect" => Action::Disconnect,
175            _ => bail!("unknown selected Bolt action"),
176        },
177    )
178}
179pub fn message(tag: u8, fields: Vec<Value>) -> Result<Vec<u8>> {
180    let value = Value::Struct { tag, fields };
181    if !wire_budget(&value) {
182        drop_wire_iteratively(value);
183        bail!("Bolt encoded message depth/node/retained-content limit");
184    }
185    let bytes = packstream::to_bytes(&value);
186    ensure!(
187        bytes.len() <= packstream::MAX_MESSAGE_BYTES,
188        "Bolt message byte limit"
189    );
190    Ok(packstream::chunk(&bytes))
191}
192pub fn auth(username: Option<&str>, password: Option<&str>) -> Value {
193    match (username, password) {
194        (Some(u), Some(p)) => Value::map([
195            ("scheme", Value::string("basic")),
196            ("principal", Value::string(u)),
197            ("credentials", Value::string(p)),
198        ]),
199        _ => Value::map([("scheme", Value::string("none"))]),
200    }
201}
202pub fn hello(minor: u8, username: Option<&str>, password: Option<&str>) -> Result<Vec<u8>> {
203    let mut entries = vec![("user_agent".into(), Value::string("NetGet/0.1"))];
204    if minor >= 3 {
205        entries.push((
206            "bolt_agent".into(),
207            Value::map([
208                ("product", Value::string("NetGet/0.1")),
209                ("language", Value::string("Rust")),
210            ]),
211        ));
212    }
213    if minor == 0 {
214        let Value::Map(auth) = auth(username, password) else {
215            unreachable!()
216        };
217        entries.extend(auth);
218    }
219    message(m::HELLO, vec![Value::Map(entries)])
220}
221pub const PROPOSALS: [u8; 16] = [0, 2, 8, 5, 0, 4, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0];
222pub fn selected_version(bytes: [u8; 4]) -> Result<u8> {
223    ensure!(
224        bytes[0] == 0 && bytes[1] == 0 && bytes[3] == 5 && bytes[2] <= 8 && bytes[2] != 5,
225        "Bolt unsupported negotiated version"
226    );
227    Ok(bytes[2])
228}
229fn drop_wire_iteratively(value: Value) {
230    let mut pending = vec![value];
231    while let Some(value) = pending.pop() {
232        match value {
233            Value::List(values) | Value::Struct { fields: values, .. } => pending.extend(values),
234            Value::Map(values) => pending.extend(values.into_iter().map(|(_, v)| v)),
235            _ => {}
236        }
237    }
238}
239fn wire_budget(value: &Value) -> bool {
240    let mut pending = vec![(value, 1usize)];
241    let mut nodes = 0usize;
242    let mut retained = 0usize;
243    while let Some((v, depth)) = pending.pop() {
244        nodes += 1;
245        retained = retained.saturating_add(std::mem::size_of::<Value>());
246        if nodes > MAX_NODES || retained > MAX_RETAINED_BYTES {
247            return false;
248        }
249        match v {
250            Value::String(s) => retained = retained.saturating_add(s.len()),
251            Value::Bytes(s) => retained = retained.saturating_add(s.len()),
252            Value::Map(v) => {
253                if depth > MAX_DEPTH || v.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
254                    return false;
255                }
256                for (k, v) in v {
257                    retained = retained.saturating_add(k.len()).saturating_add(64);
258                    pending.push((v, depth + 1));
259                }
260            }
261            Value::List(v) | Value::Struct { fields: v, .. } => {
262                if depth > MAX_DEPTH || v.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
263                    return false;
264                }
265                pending.extend(v.iter().map(|v| (v, depth + 1)));
266            }
267            _ => {}
268        }
269        if retained > MAX_RETAINED_BYTES {
270            return false;
271        }
272    }
273    true
274}
275fn integer(v: &Value) -> Result<i64> {
276    v.as_int().context("Bolt value requires integer")
277}
278fn string(v: &Value) -> Result<&str> {
279    v.as_str()
280        .filter(|s| s.len() <= MAX_TEXT)
281        .context("Bolt value requires bounded string")
282}
283fn map(v: &Value) -> Result<&[(String, Value)]> {
284    match v {
285        Value::Map(v) if v.len() <= 10000 => Ok(v),
286        _ => bail!("Bolt value requires bounded map"),
287    }
288}
289fn list(v: &Value) -> Result<&[Value]> {
290    match v {
291        Value::List(v) if v.len() <= 10000 => Ok(v),
292        _ => bail!("Bolt value requires bounded list"),
293    }
294}
295fn arity(fields: &[Value], n: usize) -> Result<()> {
296    ensure!(fields.len() == n, "Bolt value structure arity");
297    Ok(())
298}
299fn float(v: f64) -> Json {
300    serde_json::Number::from_f64(v).map(Json::Number).unwrap_or_else(||json!({"$float":if v.is_nan(){"NaN"}else if v.is_sign_positive(){"Infinity"}else{"-Infinity"}}))
301}
302fn nanos(v: &Value) -> Result<i64> {
303    let n = integer(v)?;
304    ensure!(
305        (0..1_000_000_000).contains(&n),
306        "Bolt nanosecond fraction range"
307    );
308    Ok(n)
309}
310fn day_nanos(v: &Value) -> Result<i64> {
311    let n = integer(v)?;
312    ensure!(
313        (0..86_400_000_000_000).contains(&n),
314        "Bolt time nanosecond range"
315    );
316    Ok(n)
317}
318fn offset(v: &Value) -> Result<i64> {
319    let n = integer(v)?;
320    ensure!(
321        (-64_800..=64_800).contains(&n),
322        "Bolt timezone offset range"
323    );
324    Ok(n)
325}
326fn graph(v: &Value, tag: u8) -> bool {
327    matches!(v,Value::Struct{tag:t,..} if *t==tag)
328}
329fn value(v: &Value, depth: usize, redactor: Option<&Redactor>) -> Result<Json> {
330    ensure!(depth <= MAX_DEPTH + 1, "Bolt value conversion depth limit");
331    Ok(match v {
332        Value::Null => Json::Null,
333        Value::Bool(v) => json!(v),
334        Value::Int(v) => json!(v),
335        Value::Float(v) => float(*v),
336        Value::String(v) => {
337            ensure!(v.len() <= MAX_TEXT, "Bolt result string limit");
338            json!(shown(v, redactor))
339        }
340        Value::Bytes(v) => json!({"$bytes":{"length":v.len(),"content_omitted":true}}),
341        Value::List(v) => {
342            ensure!(v.len() <= 10000, "Bolt result list limit");
343            Json::Array(
344                v.iter()
345                    .map(|v| value(v, depth + 1, redactor))
346                    .collect::<Result<_>>()?,
347            )
348        }
349        Value::Map(v) => {
350            ensure!(v.len() <= 10000, "Bolt result map limit");
351            Json::Object(
352                v.iter()
353                    .map(|(k, v)| {
354                        ensure!(k.len() <= 4096, "Bolt result key limit");
355                        Ok((shown(k, redactor), value(v, depth + 1, redactor)?))
356                    })
357                    .collect::<Result<_>>()?,
358            )
359        }
360        Value::Struct { tag, fields: f } => match tag {
361            0x4e => {
362                arity(f, 4)?;
363                let labels = list(&f[1])?
364                    .iter()
365                    .map(|v| string(v).map(|s| shown(s, redactor)))
366                    .collect::<Result<Vec<_>>>()?;
367                map(&f[2])?;
368                json!({"$node":{"id":integer(&f[0])?,"labels":labels,"properties":value(&f[2],depth+1,redactor)?,"element_id":shown_string(&f[3],redactor)?}})
369            }
370            0x52 => {
371                arity(f, 8)?;
372                map(&f[4])?;
373                json!({"$relationship":{"id":integer(&f[0])?,"start":integer(&f[1])?,"end":integer(&f[2])?,"relationship_type":shown_string(&f[3],redactor)?,"properties":value(&f[4],depth+1,redactor)?,"element_id":shown_string(&f[5],redactor)?,"start_element_id":shown_string(&f[6],redactor)?,"end_element_id":shown_string(&f[7],redactor)?}})
374            }
375            0x72 => {
376                arity(f, 4)?;
377                map(&f[2])?;
378                json!({"$unbound_relationship":{"id":integer(&f[0])?,"relationship_type":shown_string(&f[1],redactor)?,"properties":value(&f[2],depth+1,redactor)?,"element_id":shown_string(&f[3],redactor)?}})
379            }
380            0x50 => {
381                arity(f, 3)?;
382                let nodes = list(&f[0])?;
383                let rels = list(&f[1])?;
384                let indices = list(&f[2])?;
385                ensure!(
386                    !nodes.is_empty()
387                        && nodes.iter().all(|v| graph(v, 0x4e))
388                        && rels.iter().all(|v| graph(v, 0x72))
389                        && indices.len() % 2 == 0,
390                    "Bolt path table schema"
391                );
392                for pair in indices.chunks_exact(2) {
393                    let rel = integer(&pair[0])?;
394                    let node = integer(&pair[1])?;
395                    ensure!(
396                        rel.checked_abs()
397                            .is_some_and(|r| r > 0 && (r as u64) <= rels.len() as u64)
398                            && node >= 0
399                            && (node as u64) < nodes.len() as u64,
400                        "Bolt path table index"
401                    );
402                }
403                json!({"$path":{"nodes":value(&f[0],depth+1,redactor)?,"relationships":value(&f[1],depth+1,redactor)?,"indices":value(&f[2],depth+1,redactor)?}})
404            }
405            0x44 => {
406                arity(f, 1)?;
407                json!({"$date":{"days_since_epoch":integer(&f[0])?}})
408            }
409            0x54 => {
410                arity(f, 2)?;
411                json!({"$time":{"nanoseconds_since_midnight":day_nanos(&f[0])?,"offset_seconds":offset(&f[1])?}})
412            }
413            0x74 => {
414                arity(f, 1)?;
415                json!({"$local_time":{"nanoseconds_since_midnight":day_nanos(&f[0])?}})
416            }
417            0x49 => {
418                arity(f, 3)?;
419                json!({"$datetime":{"epoch_seconds":integer(&f[0])?,"nanoseconds":nanos(&f[1])?,"offset_seconds":offset(&f[2])?}})
420            }
421            0x69 => {
422                arity(f, 3)?;
423                json!({"$datetime_zone":{"epoch_seconds":integer(&f[0])?,"nanoseconds":nanos(&f[1])?,"zone_id":shown_string(&f[2],redactor)?}})
424            }
425            0x64 => {
426                arity(f, 2)?;
427                json!({"$local_datetime":{"local_epoch_seconds":integer(&f[0])?,"nanoseconds":nanos(&f[1])?}})
428            }
429            0x45 => {
430                arity(f, 4)?;
431                json!({"$duration":{"months":integer(&f[0])?,"days":integer(&f[1])?,"seconds":integer(&f[2])?,"nanoseconds":nanos(&f[3])?}})
432            }
433            0x58 | 0x59 => {
434                arity(f, if *tag == 0x58 { 3 } else { 4 })?;
435                let mut coordinates = Vec::new();
436                for v in &f[1..] {
437                    let Value::Float(v) = v else {
438                        bail!("Bolt point coordinate requires float")
439                    };
440                    ensure!(v.is_finite(), "Bolt point coordinate must be finite");
441                    coordinates.push(float(*v));
442                }
443                json!({"$point":{"srid":integer(&f[0])?,"coordinates":coordinates}})
444            }
445            _ => bail!("unsupported Bolt value structure"),
446        },
447    })
448}
449#[derive(Debug)]
450pub enum Reply {
451    Success(Json),
452    Record(Vec<Json>),
453    Ignored,
454    Failure(Json),
455}
456pub fn reply(bytes: &[u8], minor: u8) -> Result<Reply> {
457    reply_inner(bytes, minor, None)
458}
459pub fn reply_private(bytes: &[u8], minor: u8, secret: Option<&str>) -> Result<Reply> {
460    let redactor = secret.filter(|v| !v.is_empty()).map(Redactor::new);
461    reply_inner(bytes, minor, redactor.as_ref())
462}
463fn reply_inner(bytes: &[u8], minor: u8, redactor: Option<&Redactor>) -> Result<Reply> {
464    ensure!(
465        bytes.len() <= packstream::MAX_MESSAGE_BYTES,
466        "Bolt inbound byte limit"
467    );
468    let raw = packstream::decode(bytes)
469        .map_err(|_| anyhow::anyhow!("Bolt PackStream schema/depth refusal"))?;
470    ensure!(
471        wire_budget(&raw),
472        "Bolt inbound node/retained-content limit"
473    );
474    let Value::Struct { tag, fields } = raw else {
475        bail!("Bolt response must be structure")
476    };
477    match tag {
478        m::SUCCESS => {
479            arity(&fields, 1)?;
480            map(&fields[0])?;
481            let metadata = value(&fields[0], 2, None)?;
482            for name in [
483                "server",
484                "protocol_version",
485                "connection_id",
486                "bookmark",
487                "db",
488                "advertised_address",
489            ] {
490                if let Some(v) = metadata.get(name) {
491                    ensure!(v.is_string(), "Bolt success string schema");
492                }
493            }
494            if let Some(v) = metadata.get("protocol_version") {
495                ensure!(
496                    v.as_str() == Some(format!("5.{minor}").as_str()),
497                    "Bolt HELLO protocol_version mismatch"
498                );
499            }
500            for name in ["has_more", "credentials_expired"] {
501                if let Some(v) = metadata.get(name) {
502                    ensure!(v.is_boolean(), "Bolt success boolean schema");
503                }
504            }
505            for name in ["qid", "t_first", "t_last"] {
506                if let Some(v) = metadata.get(name) {
507                    ensure!(
508                        v.as_i64().is_some_and(|v| v >= 0),
509                        "Bolt success integer schema"
510                    );
511                }
512            }
513            if let Some(v) = metadata.get("fields") {
514                ensure!(
515                    v.as_array().is_some_and(|v| v.len() <= MAX_FIELDS
516                        && v.iter()
517                            .all(|v| v.as_str().is_some_and(|s| s.len() <= 4096))),
518                    "Bolt success fields schema"
519                );
520            }
521            if let Some(v) = metadata.get("type") {
522                ensure!(
523                    matches!(v.as_str(), Some("r" | "w" | "rw" | "s")),
524                    "Bolt query type schema"
525                );
526            }
527            for name in ["hints", "stats", "plan", "profile"] {
528                if let Some(v) = metadata.get(name) {
529                    ensure!(v.is_object(), "Bolt success map schema");
530                }
531            }
532            if let Some(stats) = metadata.get("stats").and_then(Json::as_object) {
533                for (key, v) in stats {
534                    if matches!(key.as_str(), "contains-updates" | "contains-system-updates") {
535                        ensure!(v.is_boolean(), "Bolt update flag schema");
536                    } else if matches!(
537                        key.as_str(),
538                        "nodes-created"
539                            | "nodes-deleted"
540                            | "relationships-created"
541                            | "relationships-deleted"
542                            | "properties-set"
543                            | "labels-added"
544                            | "labels-removed"
545                            | "indexes-added"
546                            | "indexes-removed"
547                            | "constraints-added"
548                            | "constraints-removed"
549                            | "system-updates"
550                    ) {
551                        ensure!(
552                            v.as_i64().is_some_and(|n| n >= 0),
553                            "Bolt update counter schema"
554                        );
555                    }
556                }
557            }
558            for name in ["statuses", "notifications"] {
559                if let Some(v) = metadata.get(name) {
560                    ensure!(
561                        v.as_array().is_some_and(|v| v.iter().all(Json::is_object)),
562                        "Bolt success notification schema"
563                    );
564                }
565            }
566            Ok(Reply::Success(if redactor.is_some() {
567                metadata_value(&fields[0], 2, redactor, MetaKind::Success)?
568            } else {
569                metadata
570            }))
571        }
572        m::RECORD => {
573            arity(&fields, 1)?;
574            let records = list(&fields[0])?;
575            ensure!(records.len() <= MAX_FIELDS, "Bolt record width limit");
576            Ok(Reply::Record(
577                records
578                    .iter()
579                    .map(|v| value(v, 3, redactor))
580                    .collect::<Result<_>>()?,
581            ))
582        }
583        m::IGNORED => {
584            arity(&fields, 0)?;
585            Ok(Reply::Ignored)
586        }
587        m::FAILURE => {
588            arity(&fields, 1)?;
589            map(&fields[0])?;
590            let failure = value(&fields[0], 2, None)?;
591            validate_failure(&failure, minor, 1)?;
592            Ok(Reply::Failure(if redactor.is_some() {
593                metadata_value(&fields[0], 2, redactor, MetaKind::Failure)?
594            } else {
595                failure
596            }))
597        }
598        _ => bail!("unsupported Bolt response tag"),
599    }
600}
601fn validate_failure(value: &Json, minor: u8, depth: usize) -> Result<()> {
602    ensure!(depth <= MAX_DEPTH, "Bolt failure cause depth limit");
603    ensure!(
604        value
605            .get("message")
606            .and_then(Json::as_str)
607            .is_some_and(|v| v.len() <= MAX_TEXT),
608        "Bolt failure message schema"
609    );
610    if depth == 1 {
611        text(
612            value,
613            if minor >= 7 { "neo4j_code" } else { "code" },
614            4096,
615            true,
616        )?;
617    }
618    if minor >= 7 {
619        let gql = text(value, "gql_status", 5, true)?.unwrap();
620        ensure!(
621            gql.len() == 5
622                && gql
623                    .bytes()
624                    .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()),
625            "Bolt GQL status schema"
626        );
627        ensure!(
628            value
629                .get("description")
630                .and_then(Json::as_str)
631                .is_some_and(|v| v.len() <= MAX_TEXT),
632            "Bolt failure description schema"
633        );
634        if let Some(v) = value.get("diagnostic_record") {
635            ensure!(v.is_object(), "Bolt diagnostic_record must be map");
636        }
637        if let Some(cause) = value.get("cause") {
638            ensure!(cause.is_object(), "Bolt cause must be map");
639            validate_failure(cause, minor, depth + 1)?;
640        }
641    }
642    Ok(())
643}
644// Redact dynamic wire strings and map keys before constructing typed graph wrappers.
645// Fixed metadata discriminants remain stable even when a password equals a schema key.
646struct Redactor {
647    forms: [String; 3],
648}
649impl Redactor {
650    fn new(secret: &str) -> Self {
651        let encoded = serde_json::to_string(secret).unwrap();
652        let debug = format!("{secret:?}");
653        Self {
654            forms: [
655                secret.into(),
656                encoded[1..encoded.len() - 1].into(),
657                debug[1..debug.len() - 1].into(),
658            ],
659        }
660    }
661    fn text(&self, text: &str) -> String {
662        if self.forms.iter().any(|s| text.contains(s)) {
663            crate::utils::redact::REDACTED.into()
664        } else {
665            text.into()
666        }
667    }
668}
669fn shown(text: &str, redactor: Option<&Redactor>) -> String {
670    redactor.map_or_else(|| text.into(), |r| r.text(text))
671}
672fn shown_string(v: &Value, redactor: Option<&Redactor>) -> Result<String> {
673    Ok(shown(string(v)?, redactor))
674}
675pub fn redact_text(text: &str, secret: Option<&str>) -> String {
676    secret
677        .filter(|s| !s.is_empty())
678        .map_or_else(|| text.into(), |s| Redactor::new(s).text(text))
679}
680#[derive(Clone, Copy)]
681enum MetaKind {
682    Success,
683    Failure,
684    Stats,
685    Diagnostic,
686}
687fn metadata_value(
688    v: &Value,
689    depth: usize,
690    redactor: Option<&Redactor>,
691    kind: MetaKind,
692) -> Result<Json> {
693    ensure!(depth <= MAX_DEPTH, "Bolt metadata conversion depth limit");
694    let mut result = serde_json::Map::new();
695    for (key, v) in map(v)? {
696        let fixed = match kind {
697            MetaKind::Success => matches!(
698                key.as_str(),
699                "server"
700                    | "protocol_version"
701                    | "connection_id"
702                    | "bookmark"
703                    | "db"
704                    | "advertised_address"
705                    | "has_more"
706                    | "credentials_expired"
707                    | "qid"
708                    | "t_first"
709                    | "t_last"
710                    | "fields"
711                    | "type"
712                    | "hints"
713                    | "stats"
714                    | "plan"
715                    | "profile"
716                    | "notifications"
717                    | "statuses"
718            ),
719            MetaKind::Failure => matches!(
720                key.as_str(),
721                "code"
722                    | "message"
723                    | "neo4j_code"
724                    | "gql_status"
725                    | "description"
726                    | "diagnostic_record"
727                    | "cause"
728            ),
729            MetaKind::Diagnostic => matches!(
730                key.as_str(),
731                "OPERATION"
732                    | "OPERATION_CODE"
733                    | "CURRENT_SCHEMA"
734                    | "_classification"
735                    | "_severity"
736                    | "_position"
737            ),
738            MetaKind::Stats => matches!(
739                key.as_str(),
740                "nodes-created"
741                    | "nodes-deleted"
742                    | "relationships-created"
743                    | "relationships-deleted"
744                    | "properties-set"
745                    | "labels-added"
746                    | "labels-removed"
747                    | "indexes-added"
748                    | "indexes-removed"
749                    | "constraints-added"
750                    | "constraints-removed"
751                    | "system-updates"
752                    | "contains-updates"
753                    | "contains-system-updates"
754            ),
755        };
756        let child = match (kind, key.as_str()) {
757            (MetaKind::Failure, "diagnostic_record") => {
758                metadata_value(v, depth + 1, redactor, MetaKind::Diagnostic)?
759            }
760            (MetaKind::Failure, "cause") => {
761                metadata_value(v, depth + 1, redactor, MetaKind::Failure)?
762            }
763            (MetaKind::Success, "stats") => {
764                metadata_value(v, depth + 1, redactor, MetaKind::Stats)?
765            }
766            _ => value(v, depth + 1, redactor)?,
767        };
768        result.insert(
769            if fixed {
770                key.clone()
771            } else {
772                shown(key, redactor)
773            },
774            child,
775        );
776    }
777    Ok(Json::Object(result))
778}
779
780/// Conservative converted-record retained-content measure, before adding it to a page.
781pub fn retained_size(value: &Json) -> Option<usize> {
782    let mut pending = vec![(value, 0usize)];
783    let mut nodes = 0usize;
784    let mut bytes = 0usize;
785    while let Some((value, depth)) = pending.pop() {
786        nodes += 1;
787        bytes = bytes.saturating_add(std::mem::size_of::<Json>());
788        if nodes > MAX_NODES || depth > MAX_EVENT_DEPTH || bytes > MAX_RETAINED_BYTES {
789            return None;
790        }
791        match value {
792            Json::String(v) => bytes = bytes.saturating_add(v.len()),
793            Json::Array(v) => {
794                if v.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
795                    return None;
796                }
797                pending.extend(v.iter().map(|v| (v, depth + 1)));
798            }
799            Json::Object(v) => {
800                if v.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
801                    return None;
802                }
803                for (k, v) in v {
804                    bytes = bytes.saturating_add(k.len()).saturating_add(64);
805                    pending.push((v, depth + 1));
806                }
807            }
808            _ => {}
809        }
810        if bytes > MAX_RETAINED_BYTES {
811            return None;
812        }
813    }
814    Some(bytes)
815}