Skip to main content

netget/server/bolt/
messages.rs

1//! Bolt's handshake and messages: what a client may send, and the four things a server answers
2//! with (SUCCESS, RECORD, IGNORED, FAILURE).
3//!
4//! Message shapes are those `cypher-shell` 2026.09 (neo4j-java driver 6.2) was recorded sending
5//! against a raw TCP recorder before this was written; see `CLAUDE.md` for the capture.
6
7use super::packstream::Value;
8
9/// The four bytes every Bolt connection opens with.
10pub const MAGIC: [u8; 4] = [0x60, 0x60, 0xB0, 0x17];
11
12/// Bolt major version this server speaks.
13pub const MAJOR: u8 = 5;
14/// Lowest and highest 5.x minor this server negotiates.
15pub const MIN_MINOR: u8 = 0;
16pub const MAX_MINOR: u8 = 8;
17
18/// Request tags.
19pub const HELLO: u8 = 0x01;
20pub const GOODBYE: u8 = 0x02;
21pub const RESET: u8 = 0x0F;
22pub const RUN: u8 = 0x10;
23pub const BEGIN: u8 = 0x11;
24pub const COMMIT: u8 = 0x12;
25pub const ROLLBACK: u8 = 0x13;
26pub const DISCARD: u8 = 0x2F;
27pub const PULL: u8 = 0x3F;
28pub const TELEMETRY: u8 = 0x54;
29pub const ROUTE: u8 = 0x66;
30pub const LOGON: u8 = 0x6A;
31pub const LOGOFF: u8 = 0x6B;
32
33/// Response tags.
34pub const SUCCESS: u8 = 0x70;
35pub const RECORD: u8 = 0x71;
36pub const IGNORED: u8 = 0x7E;
37pub const FAILURE: u8 = 0x7F;
38
39/// Choose a version from the client's four proposals.
40///
41/// Each proposal is four bytes, `[reserved, range, minor, major]`, meaning "any of
42/// `major.(minor - range) ..= major.minor`", in the client's order of preference. The first
43/// proposal overlapping 5.0..=5.8 wins, at the highest minor both sides support. The Bolt 5.7+
44/// handshake-manifest proposal (`00 00 01 FF`) names major 255 and is passed over, so a client
45/// offering it falls back to its plain proposals.
46pub fn negotiate(proposals: &[u8; 16]) -> Option<(u8, u8)> {
47    for [_, range, minor, major] in proposals.as_chunks::<4>().0 {
48        if *major != MAJOR {
49            continue;
50        }
51        // Every minor from MIN_MINOR (0) up is spoken, so the proposal overlaps exactly when
52        // its range reaches down to MAX_MINOR or below.
53        let lowest = minor.saturating_sub(*range);
54        let chosen = (*minor).min(MAX_MINOR);
55        if chosen >= lowest {
56            return Some((MAJOR, chosen));
57        }
58    }
59    None
60}
61
62/// One client request, decoded from its structure.
63#[derive(Debug, Clone)]
64pub enum Request {
65    Hello {
66        extra: Value,
67    },
68    Logon {
69        auth: Value,
70    },
71    Logoff,
72    Goodbye,
73    Reset,
74    Run {
75        query: String,
76        params: Value,
77        extra: Value,
78    },
79    Begin {
80        extra: Value,
81    },
82    Commit,
83    Rollback,
84    Pull {
85        n: i64,
86        qid: i64,
87    },
88    Discard {
89        n: i64,
90        qid: i64,
91    },
92    Route {
93        routing: Value,
94        extra: Value,
95    },
96    Telemetry,
97}
98
99impl Request {
100    pub fn name(&self) -> &'static str {
101        match self {
102            Request::Hello { .. } => "HELLO",
103            Request::Logon { .. } => "LOGON",
104            Request::Logoff => "LOGOFF",
105            Request::Goodbye => "GOODBYE",
106            Request::Reset => "RESET",
107            Request::Run { .. } => "RUN",
108            Request::Begin { .. } => "BEGIN",
109            Request::Commit => "COMMIT",
110            Request::Rollback => "ROLLBACK",
111            Request::Pull { .. } => "PULL",
112            Request::Discard { .. } => "DISCARD",
113            Request::Route { .. } => "ROUTE",
114            Request::Telemetry => "TELEMETRY",
115        }
116    }
117}
118
119fn map_field(fields: &[Value], i: usize) -> Result<Value, String> {
120    match fields.get(i) {
121        Some(v @ Value::Map(_)) => Ok(v.clone()),
122        _ => Err(format!("field {i} must be a map")),
123    }
124}
125
126/// Decode a request structure. `Err` names what is wrong, for the log and for a
127/// `Neo.ClientError.Request.Invalid` FAILURE.
128pub fn parse_request(value: Value) -> Result<Request, String> {
129    let Value::Struct { tag, fields } = value else {
130        return Err("a Bolt message must be a structure".to_string());
131    };
132    let arity = match tag {
133        HELLO | LOGON | BEGIN | PULL | DISCARD | TELEMETRY => 1,
134        GOODBYE | RESET | LOGOFF | COMMIT | ROLLBACK => 0,
135        RUN | ROUTE => 3,
136        _ => return Err(format!("unknown message tag 0x{tag:02X}")),
137    };
138    if fields.len() != arity {
139        return Err(format!("message 0x{tag:02X} requires {arity} fields"));
140    }
141    Ok(match tag {
142        HELLO => Request::Hello {
143            extra: map_field(&fields, 0)?,
144        },
145        LOGON => Request::Logon {
146            auth: map_field(&fields, 0)?,
147        },
148        LOGOFF => Request::Logoff,
149        GOODBYE => Request::Goodbye,
150        RESET => Request::Reset,
151        RUN => {
152            let query = fields
153                .first()
154                .and_then(Value::as_str)
155                .ok_or("RUN needs a query string")?
156                .to_string();
157            Request::Run {
158                query,
159                params: map_field(&fields, 1)?,
160                extra: map_field(&fields, 2)?,
161            }
162        }
163        BEGIN => Request::Begin {
164            extra: map_field(&fields, 0)?,
165        },
166        COMMIT => Request::Commit,
167        ROLLBACK => Request::Rollback,
168        PULL | DISCARD => {
169            let extra = map_field(&fields, 0)?;
170            let n = extra
171                .get("n")
172                .and_then(Value::as_int)
173                .ok_or("'n' must be present and an integer")?;
174            let qid = match extra.get("qid") {
175                None => -1,
176                Some(value) => value
177                    .as_int()
178                    .filter(|n| *n >= -1)
179                    .ok_or("'qid' must be an integer greater than or equal to -1")?,
180            };
181            if n == 0 || n < -1 {
182                return Err(format!("'n' must be positive or -1, got {n}"));
183            }
184            if tag == PULL {
185                Request::Pull { n, qid }
186            } else {
187                Request::Discard { n, qid }
188            }
189        }
190        ROUTE => {
191            match &fields[1] {
192                Value::List(bookmarks) if bookmarks.iter().all(|v| v.as_str().is_some()) => {}
193                _ => return Err("ROUTE bookmarks must be a list of strings".into()),
194            }
195            Request::Route {
196                routing: map_field(&fields, 0)?,
197                extra: map_field(&fields, 2)?,
198            }
199        }
200        TELEMETRY => {
201            if !fields[0].as_int().is_some_and(|n| (0..=3).contains(&n)) {
202                return Err("TELEMETRY requires an API integer from 0 to 3".into());
203            }
204            Request::Telemetry
205        }
206        other => return Err(format!("unknown message tag 0x{other:02X}")),
207    })
208}
209
210pub fn success(metadata: Vec<(&str, Value)>) -> Value {
211    Value::Struct {
212        tag: SUCCESS,
213        fields: vec![Value::map(metadata)],
214    }
215}
216
217pub fn record(values: Vec<Value>) -> Value {
218    Value::Struct {
219        tag: RECORD,
220        fields: vec![Value::List(values)],
221    }
222}
223
224pub fn ignored() -> Value {
225    Value::Struct {
226        tag: IGNORED,
227        fields: Vec::new(),
228    }
229}
230
231/// GQL status sent with every FAILURE on Bolt 5.7+: Neo4j's own status for an error that has no
232/// more specific GQL mapping ("general processing exception - unexpected error"). The Neo4j
233/// status code the model or NetGet chose travels beside it as `neo4j_code`, which is what drivers
234/// classify on.
235pub const GQL_STATUS: &str = "50N42";
236const GQL_DESCRIPTION: &str = "error: general processing exception - unexpected error";
237
238/// A FAILURE in the shape the negotiated version defines: `{code, message}` before 5.7, the GQL
239/// error object (`gql_status`, `description`, `message`, `neo4j_code`, `diagnostic_record`) from
240/// 5.7 on.
241pub fn failure(minor: u8, code: &str, message: &str) -> Value {
242    let metadata = if minor >= 7 {
243        vec![
244            ("gql_status", Value::string(GQL_STATUS)),
245            (
246                "description",
247                Value::String(format!("{GQL_DESCRIPTION}. {message}")),
248            ),
249            ("message", Value::string(message)),
250            ("neo4j_code", Value::string(code)),
251            (
252                "diagnostic_record",
253                Value::map([
254                    ("OPERATION", Value::string("")),
255                    ("OPERATION_CODE", Value::string("0")),
256                    ("CURRENT_SCHEMA", Value::string("/")),
257                ]),
258            ),
259        ]
260    } else {
261        vec![
262            ("code", Value::string(code)),
263            ("message", Value::string(message)),
264        ]
265    };
266    Value::Struct {
267        tag: FAILURE,
268        fields: vec![Value::map(metadata)],
269    }
270}