1#![forbid(unsafe_code)]
2
3use std::{
4 collections::{BTreeMap, BTreeSet},
5 fmt,
6 path::Path,
7 str::FromStr,
8};
9
10use chrono::{DateTime, Utc};
11use kcode_kweb_db::{
12 Error as KwebError, KwebDb, NodeData, NodeId, ObjectId, Owner, Provenance, TransactionId,
13};
14use rusqlite::{Connection, OptionalExtension, params};
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use sha2::{Digest, Sha256};
17
18const DIGEST_VERSION: i64 = 2;
19const RECEIPT_TABLE: &str = "kmap_session_commit_receipts";
20
21#[derive(Clone, Debug)]
26pub struct CommitRequest {
27 pub idempotency_key: String,
28 pub author: String,
29 pub source_created_at: DateTime<Utc>,
30 pub archive: Vec<u8>,
31 pub objects: BTreeMap<String, Vec<u8>>,
32 pub creates: BTreeMap<String, PlannedNode>,
33 pub updates: BTreeMap<NodeId, PlannedNode>,
34}
35
36#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct PlannedNode {
40 pub short_name: String,
41 pub short_description: String,
42 pub long_description: String,
43 pub owner: String,
44 #[serde(default)]
45 pub fixed_connections: Vec<String>,
46 #[serde(default)]
47 pub recent_connections: Vec<String>,
48 #[serde(default)]
49 pub objects: Vec<String>,
50 #[serde(
52 default,
53 rename = "includeSessionObject",
54 alias = "attachSessionArchive"
55 )]
56 pub attach_session_archive: bool,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct CommitReceipt {
62 pub transaction_id: Option<TransactionId>,
63 pub session_object_id: ObjectId,
64 pub node_ids: BTreeMap<String, NodeId>,
65 pub object_ids: BTreeMap<String, ObjectId>,
66}
67
68#[derive(Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct WireReceipt {
71 transaction_id: Option<String>,
72 session_object_id: String,
73 node_ids: BTreeMap<String, String>,
74 object_ids: BTreeMap<String, String>,
75}
76
77impl Serialize for CommitReceipt {
78 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
79 where
80 S: Serializer,
81 {
82 WireReceipt {
83 transaction_id: self.transaction_id.map(|id| id.to_string()),
84 session_object_id: self.session_object_id.to_string(),
85 node_ids: self
86 .node_ids
87 .iter()
88 .map(|(pending, id)| (pending.clone(), id.to_string()))
89 .collect(),
90 object_ids: self
91 .object_ids
92 .iter()
93 .map(|(pending, id)| (pending.clone(), id.to_string()))
94 .collect(),
95 }
96 .serialize(serializer)
97 }
98}
99
100impl<'de> Deserialize<'de> for CommitReceipt {
101 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
102 where
103 D: Deserializer<'de>,
104 {
105 let wire = WireReceipt::deserialize(deserializer)?;
106 Ok(Self {
107 transaction_id: wire
108 .transaction_id
109 .map(|value| TransactionId::from_str(&value).map_err(serde::de::Error::custom))
110 .transpose()?,
111 session_object_id: ObjectId::from_str(&wire.session_object_id)
112 .map_err(serde::de::Error::custom)?,
113 node_ids: parse_id_map(wire.node_ids)?,
114 object_ids: parse_id_map(wire.object_ids)?,
115 })
116 }
117}
118
119fn parse_id_map<T, E>(values: BTreeMap<String, String>) -> Result<BTreeMap<String, T>, E>
120where
121 T: FromStr,
122 T::Err: fmt::Display,
123 E: serde::de::Error,
124{
125 values
126 .into_iter()
127 .map(|(key, value)| T::from_str(&value).map(|id| (key, id)).map_err(E::custom))
128 .collect()
129}
130
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132#[non_exhaustive]
133pub enum ErrorKind {
134 InvalidInput,
135 NotFound,
136 Conflict,
137 Internal,
138}
139
140#[derive(Debug)]
141pub struct Error {
142 kind: ErrorKind,
143 message: String,
144}
145
146impl Error {
147 pub fn kind(&self) -> ErrorKind {
148 self.kind
149 }
150
151 fn invalid(message: impl Into<String>) -> Self {
152 Self {
153 kind: ErrorKind::InvalidInput,
154 message: message.into(),
155 }
156 }
157
158 fn conflict(message: impl Into<String>) -> Self {
159 Self {
160 kind: ErrorKind::Conflict,
161 message: message.into(),
162 }
163 }
164
165 fn internal(error: impl fmt::Display) -> Self {
166 Self {
167 kind: ErrorKind::Internal,
168 message: error.to_string(),
169 }
170 }
171}
172
173impl fmt::Display for Error {
174 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
175 formatter.write_str(&self.message)
176 }
177}
178
179impl std::error::Error for Error {}
180
181impl From<KwebError> for Error {
182 fn from(error: KwebError) -> Self {
183 let kind = match error {
184 KwebError::InvalidInput(_) | KwebError::InvalidTransaction(_) => {
185 ErrorKind::InvalidInput
186 }
187 KwebError::NotFound(_) => ErrorKind::NotFound,
188 _ => ErrorKind::Internal,
189 };
190 Self {
191 kind,
192 message: error.to_string(),
193 }
194 }
195}
196
197pub fn commit_session(
204 database: &KwebDb,
205 receipt_database: &Path,
206 request: CommitRequest,
207) -> Result<CommitReceipt, Error> {
208 validate(&request)?;
209 let request_digest = request_digest(&request);
210 let receipts = open_receipts(receipt_database)?;
211 if let Some(receipt) = recover_or_replay(database, &receipts, &request, request_digest)? {
212 return Ok(receipt);
213 }
214
215 receipts
216 .execute(
217 "INSERT INTO kmap_session_commit_receipts(
218 session_id,request_sha256,digest_version,prepared_json,result_json,
219 started_at,committed_at
220 ) VALUES(?1,?2,?3,NULL,NULL,?4,NULL)",
221 params![
222 &request.idempotency_key,
223 request_digest.as_slice(),
224 DIGEST_VERSION,
225 Utc::now().to_rfc3339(),
226 ],
227 )
228 .map_err(Error::internal)?;
229
230 let mut transaction = database.start_transaction(Provenance {
231 author: request.author.clone(),
232 source: "kennedy-session".into(),
233 source_created_at: request.source_created_at,
234 data: format!("Kennedy session {}.", request.idempotency_key),
235 })?;
236
237 let mut object_ids = BTreeMap::new();
238 for (pending, payload) in request.objects {
239 object_ids.insert(pending, transaction.create_object(payload)?);
240 }
241 let archive = replace_pending_object_tokens(&request.archive, &object_ids);
242 let session_object_id = transaction.create_object(archive)?;
243
244 let mut node_ids = BTreeMap::new();
245 for pending in request.creates.keys() {
246 node_ids.insert(pending.clone(), transaction.reserve_node_id()?);
247 }
248 for (pending, data) in request.creates {
249 let resolved = resolve_node(data, &node_ids, &object_ids, session_object_id)?;
250 transaction.create_reserved_node(node_ids[&pending], resolved)?;
251 }
252 for (id, data) in request.updates {
253 let resolved = resolve_node(data, &node_ids, &object_ids, session_object_id)?;
254 transaction.update_node(id, resolved)?;
255 }
256
257 let mut receipt = CommitReceipt {
258 transaction_id: None,
259 session_object_id,
260 node_ids,
261 object_ids,
262 };
263 let prepared_json = serde_json::to_string(&receipt).map_err(Error::internal)?;
264 require_one(
265 receipts
266 .execute(
267 "UPDATE kmap_session_commit_receipts
268 SET prepared_json=?2
269 WHERE session_id=?1 AND prepared_json IS NULL AND result_json IS NULL",
270 params![&request.idempotency_key, prepared_json],
271 )
272 .map_err(Error::internal)?,
273 "session commit preparation receipt disappeared",
274 )?;
275
276 receipt.transaction_id = Some(transaction.finalize()?);
277 let result_json = serde_json::to_string(&receipt).map_err(Error::internal)?;
278 require_one(
279 receipts
280 .execute(
281 "UPDATE kmap_session_commit_receipts
282 SET result_json=?2,committed_at=?3
283 WHERE session_id=?1 AND result_json IS NULL",
284 params![
285 &request.idempotency_key,
286 result_json,
287 Utc::now().to_rfc3339(),
288 ],
289 )
290 .map_err(Error::internal)?,
291 "session commit receipt disappeared during mutation",
292 )?;
293 Ok(receipt)
294}
295
296fn open_receipts(path: &Path) -> Result<Connection, Error> {
297 let connection = Connection::open(path).map_err(Error::internal)?;
298 connection
299 .execute_batch(
300 "PRAGMA foreign_keys=ON;
301 PRAGMA journal_mode=WAL;
302 PRAGMA busy_timeout=15000;
303 CREATE TABLE IF NOT EXISTS kmap_session_commit_receipts (
304 session_id TEXT PRIMARY KEY,
305 request_sha256 BLOB NOT NULL CHECK(length(request_sha256)=32),
306 digest_version INTEGER NOT NULL DEFAULT 2,
307 prepared_json TEXT,
308 result_json TEXT,
309 started_at TEXT NOT NULL,
310 committed_at TEXT,
311 CHECK((result_json IS NULL) = (committed_at IS NULL))
312 );",
313 )
314 .map_err(Error::internal)?;
315 let columns = {
316 let mut statement = connection
317 .prepare(&format!("PRAGMA table_info({RECEIPT_TABLE})"))
318 .map_err(Error::internal)?;
319 statement
320 .query_map([], |row| row.get::<_, String>(1))
321 .map_err(Error::internal)?
322 .collect::<rusqlite::Result<BTreeSet<_>>>()
323 .map_err(Error::internal)?
324 };
325 if !columns.contains("prepared_json") {
326 connection
327 .execute(
328 "ALTER TABLE kmap_session_commit_receipts ADD COLUMN prepared_json TEXT",
329 [],
330 )
331 .map_err(Error::internal)?;
332 }
333 if !columns.contains("digest_version") {
334 connection
335 .execute(
336 "ALTER TABLE kmap_session_commit_receipts
337 ADD COLUMN digest_version INTEGER NOT NULL DEFAULT 1",
338 [],
339 )
340 .map_err(Error::internal)?;
341 }
342 Ok(connection)
343}
344
345fn recover_or_replay(
346 database: &KwebDb,
347 receipts: &Connection,
348 request: &CommitRequest,
349 request_digest: [u8; 32],
350) -> Result<Option<CommitReceipt>, Error> {
351 let existing = receipts
352 .query_row(
353 "SELECT request_sha256,digest_version,prepared_json,result_json
354 FROM kmap_session_commit_receipts WHERE session_id=?1",
355 [&request.idempotency_key],
356 |row| {
357 Ok((
358 row.get::<_, Vec<u8>>(0)?,
359 row.get::<_, i64>(1)?,
360 row.get::<_, Option<String>>(2)?,
361 row.get::<_, Option<String>>(3)?,
362 ))
363 },
364 )
365 .optional()
366 .map_err(Error::internal)?;
367 let Some((stored_digest, digest_version, prepared, result)) = existing else {
368 return Ok(None);
369 };
370 if digest_version == DIGEST_VERSION && stored_digest.as_slice() != request_digest {
374 return Err(Error::conflict(
375 "idempotency key was already used for a different session commit",
376 ));
377 }
378 if let Some(result) = result {
379 return serde_json::from_str(&result)
380 .map(Some)
381 .map_err(Error::internal);
382 }
383 if let Some(prepared) = prepared {
384 let recovered: CommitReceipt = serde_json::from_str(&prepared).map_err(Error::internal)?;
385 match database.get_object(recovered.session_object_id) {
386 Ok(_) => {
387 require_one(
388 receipts
389 .execute(
390 "UPDATE kmap_session_commit_receipts
391 SET result_json=prepared_json,committed_at=?2
392 WHERE session_id=?1 AND result_json IS NULL",
393 params![&request.idempotency_key, Utc::now().to_rfc3339()],
394 )
395 .map_err(Error::internal)?,
396 "session recovery receipt disappeared",
397 )?;
398 return Ok(Some(recovered));
399 }
400 Err(KwebError::NotFound(_)) => {}
401 Err(error) => return Err(error.into()),
402 }
403 }
404 receipts
405 .execute(
406 "DELETE FROM kmap_session_commit_receipts
407 WHERE session_id=?1 AND result_json IS NULL",
408 [&request.idempotency_key],
409 )
410 .map_err(Error::internal)?;
411 Ok(None)
412}
413
414fn validate(request: &CommitRequest) -> Result<(), Error> {
415 if request.idempotency_key.trim().is_empty() || request.idempotency_key.len() > 1024 {
416 return Err(Error::invalid(
417 "idempotency key must contain between 1 and 1024 bytes",
418 ));
419 }
420 let mut pending_ids = BTreeSet::new();
421 for pending in request.objects.keys().chain(request.creates.keys()) {
422 validate_pending_id(pending)?;
423 if !pending_ids.insert(pending) {
424 return Err(Error::invalid(format!(
425 "pending ID {pending} is used for both an object and a node"
426 )));
427 }
428 }
429 for node in request.creates.values().chain(request.updates.values()) {
430 validate_node(node, &request.creates, &request.objects)?;
431 }
432 Ok(())
433}
434
435fn validate_pending_id(value: &str) -> Result<(), Error> {
436 let number = value
437 .strip_prefix("pending:")
438 .and_then(|number| number.parse::<u64>().ok())
439 .filter(|number| *number > 0);
440 if number.is_none() || format!("pending:{}", number.unwrap_or_default()) != value {
441 return Err(Error::invalid(format!(
442 "{value:?} is not a canonical pending ID"
443 )));
444 }
445 Ok(())
446}
447
448fn validate_node(
449 node: &PlannedNode,
450 creates: &BTreeMap<String, PlannedNode>,
451 objects: &BTreeMap<String, Vec<u8>>,
452) -> Result<(), Error> {
453 if !matches!(node.owner.as_str(), "self" | "unowned") {
454 validate_node_ref(&node.owner, creates)?;
455 }
456 for value in node
457 .fixed_connections
458 .iter()
459 .chain(&node.recent_connections)
460 {
461 validate_node_ref(value, creates)?;
462 }
463 for value in &node.objects {
464 if value.starts_with("pending:") {
465 validate_pending_id(value)?;
466 if !objects.contains_key(value) {
467 return Err(Error::invalid(format!("unresolved pending object {value}")));
468 }
469 } else {
470 ObjectId::from_str(value).map_err(Error::from)?;
471 }
472 }
473 Ok(())
474}
475
476fn validate_node_ref(value: &str, creates: &BTreeMap<String, PlannedNode>) -> Result<(), Error> {
477 if value.starts_with("pending:") {
478 validate_pending_id(value)?;
479 if !creates.contains_key(value) {
480 return Err(Error::invalid(format!("unresolved pending node {value}")));
481 }
482 Ok(())
483 } else {
484 NodeId::from_str(value).map(|_| ()).map_err(Error::from)
485 }
486}
487
488fn resolve_node(
489 node: PlannedNode,
490 node_ids: &BTreeMap<String, NodeId>,
491 object_ids: &BTreeMap<String, ObjectId>,
492 session_object_id: ObjectId,
493) -> Result<NodeData, Error> {
494 let resolve_node_id = |value: &str| {
495 if value.starts_with("pending:") {
496 node_ids
497 .get(value)
498 .copied()
499 .ok_or_else(|| Error::invalid(format!("unresolved pending node {value}")))
500 } else {
501 NodeId::from_str(value).map_err(Error::from)
502 }
503 };
504 let owner = match node.owner.as_str() {
505 "unowned" => Owner::Unowned,
506 "self" => Owner::SelfNode,
507 value => Owner::Node(resolve_node_id(value)?),
508 };
509 let mut objects = node
510 .objects
511 .iter()
512 .map(|value| {
513 if value.starts_with("pending:") {
514 object_ids
515 .get(value)
516 .copied()
517 .ok_or_else(|| Error::invalid(format!("unresolved pending object {value}")))
518 } else {
519 ObjectId::from_str(value).map_err(Error::from)
520 }
521 })
522 .collect::<Result<Vec<_>, _>>()?;
523 if node.attach_session_archive && !objects.contains(&session_object_id) {
524 objects.push(session_object_id);
525 }
526 Ok(NodeData {
527 short_name: replace_pending_object_tokens_in_text(&node.short_name, object_ids),
528 short_description: replace_pending_object_tokens_in_text(
529 &node.short_description,
530 object_ids,
531 ),
532 long_description: replace_pending_object_tokens_in_text(&node.long_description, object_ids),
533 owner,
534 fixed_connections: node
535 .fixed_connections
536 .iter()
537 .map(|value| resolve_node_id(value))
538 .collect::<Result<_, _>>()?,
539 recent_connections: node
540 .recent_connections
541 .iter()
542 .map(|value| resolve_node_id(value))
543 .collect::<Result<_, _>>()?,
544 objects,
545 })
546}
547
548fn replace_pending_object_tokens(bytes: &[u8], object_ids: &BTreeMap<String, ObjectId>) -> Vec<u8> {
549 let Ok(text) = std::str::from_utf8(bytes) else {
550 return bytes.to_vec();
551 };
552 replace_pending_object_tokens_in_text(text, object_ids).into_bytes()
553}
554
555fn replace_pending_object_tokens_in_text(
556 text: &str,
557 object_ids: &BTreeMap<String, ObjectId>,
558) -> String {
559 if object_ids.is_empty() || !text.contains("pending:") {
560 return text.into();
561 }
562 let mut output = String::with_capacity(text.len());
563 let mut cursor = 0;
564 while let Some(relative) = text[cursor..].find("pending:") {
565 let start = cursor + relative;
566 let number_start = start + "pending:".len();
567 let number_len = text[number_start..]
568 .bytes()
569 .take_while(u8::is_ascii_digit)
570 .count();
571 if number_len == 0 {
572 output.push_str(&text[cursor..number_start]);
573 cursor = number_start;
574 continue;
575 }
576 let end = number_start + number_len;
577 let token = &text[start..end];
578 let left_boundary = start == 0
579 || !text[..start]
580 .chars()
581 .next_back()
582 .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_');
583 let right_boundary = text[end..]
584 .chars()
585 .next()
586 .is_none_or(|character| !character.is_ascii_alphanumeric() && character != '_');
587 if left_boundary
588 && right_boundary
589 && let Some(id) = object_ids.get(token)
590 {
591 output.push_str(&text[cursor..start]);
592 output.push_str(&id.to_string());
593 cursor = end;
594 continue;
595 }
596 output.push_str(&text[cursor..end]);
597 cursor = end;
598 }
599 output.push_str(&text[cursor..]);
600 output
601}
602
603fn request_digest(request: &CommitRequest) -> [u8; 32] {
604 let mut digest = Sha256::new();
605 digest.update(b"kcode-commit-session request v2\0");
606 hash_bytes(&mut digest, request.idempotency_key.as_bytes());
607 hash_bytes(&mut digest, request.author.as_bytes());
608 digest.update(request.source_created_at.timestamp().to_be_bytes());
609 digest.update(
610 request
611 .source_created_at
612 .timestamp_subsec_nanos()
613 .to_be_bytes(),
614 );
615 hash_bytes(&mut digest, &request.archive);
616 hash_u64(&mut digest, request.objects.len());
617 for (pending, bytes) in &request.objects {
618 hash_bytes(&mut digest, pending.as_bytes());
619 hash_bytes(&mut digest, bytes);
620 }
621 hash_u64(&mut digest, request.creates.len());
622 for (pending, node) in &request.creates {
623 hash_bytes(&mut digest, pending.as_bytes());
624 hash_node(&mut digest, node);
625 }
626 hash_u64(&mut digest, request.updates.len());
627 for (id, node) in &request.updates {
628 digest.update(id.to_bytes());
629 hash_node(&mut digest, node);
630 }
631 digest.finalize().into()
632}
633
634fn hash_node(digest: &mut Sha256, node: &PlannedNode) {
635 hash_bytes(digest, node.short_name.as_bytes());
636 hash_bytes(digest, node.short_description.as_bytes());
637 hash_bytes(digest, node.long_description.as_bytes());
638 hash_bytes(digest, node.owner.as_bytes());
639 hash_strings(digest, &node.fixed_connections);
640 hash_strings(digest, &node.recent_connections);
641 hash_strings(digest, &node.objects);
642 digest.update([u8::from(node.attach_session_archive)]);
643}
644
645fn hash_strings(digest: &mut Sha256, values: &[String]) {
646 hash_u64(digest, values.len());
647 for value in values {
648 hash_bytes(digest, value.as_bytes());
649 }
650}
651
652fn hash_bytes(digest: &mut Sha256, bytes: &[u8]) {
653 hash_u64(digest, bytes.len());
654 digest.update(bytes);
655}
656
657fn hash_u64(digest: &mut Sha256, value: usize) {
658 digest.update((value as u64).to_be_bytes());
659}
660
661fn require_one(updated: usize, message: &'static str) -> Result<(), Error> {
662 if updated == 1 {
663 Ok(())
664 } else {
665 Err(Error::internal(message))
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn planned_node_keeps_checkpoint_field_compatible() {
675 let node = PlannedNode {
676 short_name: "name".into(),
677 short_description: String::new(),
678 long_description: String::new(),
679 owner: "self".into(),
680 fixed_connections: Vec::new(),
681 recent_connections: Vec::new(),
682 objects: Vec::new(),
683 attach_session_archive: true,
684 };
685 let value = serde_json::to_value(&node).unwrap();
686 assert_eq!(value["includeSessionObject"], true);
687 assert_eq!(
688 serde_json::from_value::<PlannedNode>(serde_json::json!({
689 "shortName": "name",
690 "shortDescription": "",
691 "longDescription": "",
692 "owner": "self",
693 "attachSessionArchive": true
694 }))
695 .unwrap(),
696 node
697 );
698 }
699}