1use std::fmt;
7use std::rc::Rc;
8
9use serde_json::{Number, Value};
10
11use crate::json::{strict_canonical_json_bytes, validate_strict_json_value};
12use crate::protocol::AnyValue;
13
14const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
15
16pub type NodeVersionHashFn = Rc<dyn Fn(&[u8]) -> String>;
18
19#[derive(Clone)]
20pub enum NodeVersioningPolicy {
22 Disabled,
24 Level0,
26 Level1 {
28 hash: Option<NodeVersionHashFn>,
30 },
31}
32
33impl fmt::Debug for NodeVersioningPolicy {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::Disabled => f.write_str("Disabled"),
37 Self::Level0 => f.write_str("Level0"),
38 Self::Level1 { hash } => f
39 .debug_struct("Level1")
40 .field("hash", &hash.as_ref().map(|_| "<installed>"))
41 .finish(),
42 }
43 }
44}
45
46#[derive(Clone)]
47pub enum ResolvedNodeVersioningPolicy {
49 Disabled,
51 Level0,
53 Level1 {
55 hash: NodeVersionHashFn,
57 },
58}
59
60impl fmt::Debug for ResolvedNodeVersioningPolicy {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::Disabled => f.write_str("Disabled"),
64 Self::Level0 => f.write_str("Level0"),
65 Self::Level1 { .. } => f.write_str("Level1 { hash: <installed> }"),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum NodeVersion {
73 V0 {
75 counter: u64,
77 },
78 V1 {
80 counter: u64,
82 cid: String,
84 prev: Option<String>,
86 },
87}
88
89#[derive(Debug, Clone)]
90pub(crate) enum RestoredNodeVersion {
91 Disabled,
92 Version(NodeVersion),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub(crate) struct NodeVersioningError {
97 message: String,
98}
99
100impl NodeVersioningError {
101 fn new(message: impl Into<String>) -> Self {
102 Self {
103 message: message.into(),
104 }
105 }
106}
107
108impl fmt::Display for NodeVersioningError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.write_str(&self.message)
111 }
112}
113
114impl std::error::Error for NodeVersioningError {}
115
116pub(crate) fn resolve_node_versioning_policy(
117 policy: Option<NodeVersioningPolicy>,
118) -> ResolvedNodeVersioningPolicy {
119 match policy {
120 Some(NodeVersioningPolicy::Disabled) => ResolvedNodeVersioningPolicy::Disabled,
121 None | Some(NodeVersioningPolicy::Level0) => ResolvedNodeVersioningPolicy::Level0,
122 Some(NodeVersioningPolicy::Level1 { hash }) => ResolvedNodeVersioningPolicy::Level1 {
123 hash: hash.unwrap_or_else(default_node_version_hash_fn),
124 },
125 }
126}
127
128fn default_node_version_hash_fn() -> NodeVersionHashFn {
129 Rc::new(default_node_version_hash)
130}
131
132pub fn default_node_version_hash(bytes: &[u8]) -> String {
134 format!("fnv1a64:{}", fnv1a64(bytes))
135}
136
137fn fnv1a64(bytes: &[u8]) -> String {
138 let mut hash = 0xcbf2_9ce4_8422_2325u64;
139 for byte in bytes {
140 hash ^= u64::from(*byte);
141 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
142 }
143 format!("{hash:016x}")
144}
145
146pub(crate) fn create_node_version(
147 policy: &ResolvedNodeVersioningPolicy,
148 initial: Option<&AnyValue>,
149) -> Result<Option<NodeVersion>, NodeVersioningError> {
150 match policy {
151 ResolvedNodeVersioningPolicy::Disabled => Ok(None),
152 ResolvedNodeVersioningPolicy::Level0 => Ok(Some(NodeVersion::V0 { counter: 0 })),
153 ResolvedNodeVersioningPolicy::Level1 { hash } => {
154 let cid = hash_node_data(hash, initial)?;
155 Ok(Some(NodeVersion::V1 {
156 counter: 0,
157 cid,
158 prev: None,
159 }))
160 }
161 }
162}
163
164pub(crate) fn assert_node_version_data_compatible(
165 policy: &ResolvedNodeVersioningPolicy,
166 value: &AnyValue,
167) -> Result<(), NodeVersioningError> {
168 if matches!(policy, ResolvedNodeVersioningPolicy::Level1 { .. }) {
169 let json = any_to_strict_json(value, "$")?;
170 let _ = strict_canonical_json_bytes(&json).map_err(|err| {
171 NodeVersioningError::new(format!(
172 "node versioning: DATA is not strict canonical JSON compatible (D112): {err}"
173 ))
174 })?;
175 }
176 Ok(())
177}
178
179pub(crate) fn advance_node_version(
180 current: Option<&NodeVersion>,
181 policy: &ResolvedNodeVersioningPolicy,
182 value: &AnyValue,
183) -> Result<Option<NodeVersion>, NodeVersioningError> {
184 match policy {
185 ResolvedNodeVersioningPolicy::Disabled => Ok(None),
186 ResolvedNodeVersioningPolicy::Level0 => Ok(Some(NodeVersion::V0 {
187 counter: next_counter(current)?,
188 })),
189 ResolvedNodeVersioningPolicy::Level1 { hash } => {
190 let previous = match current {
191 Some(NodeVersion::V1 { cid, .. }) => Some(cid.clone()),
192 _ => None,
193 };
194 Ok(Some(NodeVersion::V1 {
195 counter: next_counter(current)?,
196 cid: hash_node_data(hash, Some(value))?,
197 prev: previous,
198 }))
199 }
200 }
201}
202
203pub(crate) fn validate_node_version_json(
204 value: &Value,
205 path: &str,
206) -> Result<NodeVersion, NodeVersioningError> {
207 validate_strict_json_value(value, path).map_err(|err| {
208 NodeVersioningError::new(format!(
209 "restore_graph: node version metadata is not strict canonical JSON compatible (D112): {err}"
210 ))
211 })?;
212 let Value::Object(record) = value else {
213 return Err(NodeVersioningError::new(format!(
214 "restore_graph: {path} must be an object"
215 )));
216 };
217 match record.get("level").and_then(Value::as_u64) {
218 Some(0) => {
219 require_fields(record, &["level", "counter"], path)?;
220 let counter = read_counter(record.get("counter"), path)?;
221 Ok(NodeVersion::V0 { counter })
222 }
223 Some(1) => {
224 require_fields(record, &["level", "counter", "cid", "prev"], path)?;
225 let counter = read_counter(record.get("counter"), path)?;
226 let cid = record
227 .get("cid")
228 .and_then(Value::as_str)
229 .ok_or_else(|| {
230 NodeVersioningError::new(format!("restore_graph: {path}.cid must be a string"))
231 })?
232 .to_owned();
233 let prev = match record.get("prev") {
234 Some(Value::Null) => None,
235 Some(Value::String(value)) => Some(value.clone()),
236 _ => {
237 return Err(NodeVersioningError::new(format!(
238 "restore_graph: {path}.prev must be a string or null"
239 )));
240 }
241 };
242 if counter == 0 && prev.is_some() {
243 return Err(NodeVersioningError::new(format!(
244 "restore_graph: {path}.prev must be null when counter is 0"
245 )));
246 }
247 if counter > 0 && prev.is_none() {
248 return Err(NodeVersioningError::new(format!(
249 "restore_graph: {path}.prev must be a string when counter is > 0"
250 )));
251 }
252 Ok(NodeVersion::V1 { counter, cid, prev })
253 }
254 _ => Err(NodeVersioningError::new(format!(
255 "restore_graph: {path}.level must be 0 or 1"
256 ))),
257 }
258}
259
260pub(crate) fn node_version_to_json(version: &NodeVersion) -> Value {
261 let mut record = serde_json::Map::new();
262 match version {
263 NodeVersion::V0 { counter } => {
264 record.insert("level".to_owned(), Value::Number(Number::from(0)));
265 record.insert("counter".to_owned(), Value::Number(Number::from(*counter)));
266 }
267 NodeVersion::V1 { counter, cid, prev } => {
268 record.insert("level".to_owned(), Value::Number(Number::from(1)));
269 record.insert("counter".to_owned(), Value::Number(Number::from(*counter)));
270 record.insert("cid".to_owned(), Value::String(cid.clone()));
271 record.insert(
272 "prev".to_owned(),
273 prev.clone().map_or(Value::Null, Value::String),
274 );
275 }
276 }
277 Value::Object(record)
278}
279
280pub(crate) fn verify_restored_node_version(
281 policy: &ResolvedNodeVersioningPolicy,
282 restored: Option<&Value>,
283 has_data: bool,
284 cache: Option<&AnyValue>,
285 path: &str,
286) -> Result<RestoredNodeVersion, NodeVersioningError> {
287 let Some(restored) = restored else {
288 return match policy {
289 ResolvedNodeVersioningPolicy::Disabled => Ok(RestoredNodeVersion::Disabled),
290 _ => Err(NodeVersioningError::new(
291 "restore_graph: checkpoint node version metadata is required by the selected node versioning policy (D109)",
292 )),
293 };
294 };
295 let version = validate_node_version_json(restored, path)?;
296 match (policy, &version) {
297 (ResolvedNodeVersioningPolicy::Disabled, _) => Err(NodeVersioningError::new(
298 "restore_graph: checkpoint node version metadata is present but node versioning is disabled",
299 )),
300 (ResolvedNodeVersioningPolicy::Level0, NodeVersion::V0 { .. }) => {
301 Ok(RestoredNodeVersion::Version(version))
302 }
303 (ResolvedNodeVersioningPolicy::Level0, NodeVersion::V1 { .. }) => Err(
304 NodeVersioningError::new(
305 "restore_graph: checkpoint node version level 1 requires matching node versioning policy",
306 ),
307 ),
308 (ResolvedNodeVersioningPolicy::Level1 { .. }, NodeVersion::V0 { .. }) => Err(
309 NodeVersioningError::new(
310 "restore_graph: checkpoint node version level 0 requires matching node versioning policy",
311 ),
312 ),
313 (ResolvedNodeVersioningPolicy::Level1 { hash }, NodeVersion::V1 { counter, cid, .. }) => {
314 if !has_data && *counter > 0 {
315 return Err(NodeVersioningError::new(
316 "restore_graph: checkpoint node version cid cannot be verified without current DATA under V1 versioning (D109)",
317 ));
318 }
319 let expected = hash_node_data(hash, if has_data { cache } else { None })?;
320 if &expected != cid {
321 return Err(NodeVersioningError::new(
322 "restore_graph: checkpoint node version cid does not match the selected node versioning hash policy (D109)",
323 ));
324 }
325 Ok(RestoredNodeVersion::Version(version))
326 }
327 }
328}
329
330fn next_counter(current: Option<&NodeVersion>) -> Result<u64, NodeVersioningError> {
331 let Some(current) = current else {
332 return Ok(1);
333 };
334 current
335 .counter()
336 .checked_add(1)
337 .ok_or_else(|| {
338 NodeVersioningError::new(
339 "node versioning: counter overflow while advancing node runtime version (D109)",
340 )
341 })
342 .and_then(|counter| {
343 if counter > MAX_SAFE_INTEGER {
344 Err(NodeVersioningError::new(
345 "node versioning: counter exceeded the strict JSON safe-integer range (D109)",
346 ))
347 } else {
348 Ok(counter)
349 }
350 })
351}
352
353fn require_fields(
354 record: &serde_json::Map<String, Value>,
355 fields: &[&str],
356 path: &str,
357) -> Result<(), NodeVersioningError> {
358 if record.len() != fields.len() || fields.iter().any(|field| !record.contains_key(*field)) {
359 return Err(NodeVersioningError::new(format!(
360 "restore_graph: {path} has unexpected node version fields"
361 )));
362 }
363 Ok(())
364}
365
366fn read_counter(value: Option<&Value>, path: &str) -> Result<u64, NodeVersioningError> {
367 let counter = value.and_then(Value::as_u64).ok_or_else(|| {
368 NodeVersioningError::new(format!(
369 "restore_graph: {path}.counter must be a non-negative safe integer"
370 ))
371 })?;
372 if counter > MAX_SAFE_INTEGER {
373 return Err(NodeVersioningError::new(format!(
374 "restore_graph: {path}.counter must be a non-negative safe integer"
375 )));
376 }
377 Ok(counter)
378}
379
380fn hash_node_data(
381 hash: &NodeVersionHashFn,
382 value: Option<&AnyValue>,
383) -> Result<String, NodeVersioningError> {
384 let json = match value {
385 Some(value) => any_to_strict_json(value, "$")?,
386 None => absent_v1_seed(),
387 };
388 let bytes = strict_canonical_json_bytes(&json).map_err(|err| {
389 NodeVersioningError::new(format!(
390 "node versioning: DATA is not strict canonical JSON compatible (D112): {err}"
391 ))
392 })?;
393 Ok(hash(&bytes))
394}
395
396fn absent_v1_seed() -> Value {
397 let mut record = serde_json::Map::new();
398 record.insert(
399 "@graphrefly/node-version".to_owned(),
400 Value::String("v1-absent".to_owned()),
401 );
402 Value::Object(record)
403}
404
405fn any_to_strict_json(value: &AnyValue, path: &str) -> Result<Value, NodeVersioningError> {
406 let out = if let Some(v) = value.downcast_ref::<Value>() {
407 v.clone()
408 } else if let Some(v) = value.downcast_ref::<String>() {
409 Value::String(v.clone())
410 } else if let Some(v) = value.downcast_ref::<bool>() {
411 Value::Bool(*v)
412 } else if let Some(v) = value.downcast_ref::<i32>() {
413 Value::Number(Number::from(*v))
414 } else if let Some(v) = value.downcast_ref::<i64>() {
415 Value::Number(Number::from(*v))
416 } else if let Some(v) = value.downcast_ref::<u32>() {
417 Value::Number(Number::from(*v))
418 } else if let Some(v) = value.downcast_ref::<u64>() {
419 Value::Number(Number::from(*v))
420 } else if let Some(v) = value.downcast_ref::<usize>() {
421 Value::Number(Number::from(*v as u64))
422 } else if let Some(v) = value.downcast_ref::<f64>() {
423 Number::from_f64(*v).map(Value::Number).ok_or_else(|| {
424 NodeVersioningError::new(format!(
425 "node versioning: DATA at {path} is not strict JSON compatible"
426 ))
427 })?
428 } else {
429 return Err(NodeVersioningError::new(format!(
430 "node versioning: DATA at {path} is not strict JSON compatible"
431 )));
432 };
433 validate_strict_json_value(&out, path).map_err(|err| {
434 NodeVersioningError::new(format!(
435 "node versioning: DATA is not strict canonical JSON compatible (D112): {err}"
436 ))
437 })?;
438 Ok(out)
439}
440
441impl NodeVersion {
442 pub fn level(&self) -> u8 {
444 match self {
445 Self::V0 { .. } => 0,
446 Self::V1 { .. } => 1,
447 }
448 }
449
450 pub fn counter(&self) -> u64 {
452 match self {
453 Self::V0 { counter } | Self::V1 { counter, .. } => *counter,
454 }
455 }
456}