Skip to main content

cortexkit_model_batch/
identity.rs

1//! Batch identity: what decides whether a re-sent `batch_id` is a replay or
2//! `batch_id_reuse` (`RULES.md` ยง2).
3//!
4//! Each part of a batch body is hashed as RFC 8785 (JCS) canonical JSON, so
5//! the key order of an object, whitespace, and number spellings JSON treats
6//! as equal (`1.0` and `1`, `1e30` and `1E+30`) can't turn a retry into a
7//! reuse. An item's images are hashed in their canonical form
8//! ([`Image::canonical`]), so a `data:` URL and the same image as
9//! `{content_type, base64}` are the same item. Each role names its parts
10//! and their order; [`first_difference`] names the first that differs.
11
12use serde::Serialize;
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16use crate::image::Image;
17
18/// Why an identity could not be computed: the request holds a value JCS
19/// cannot encode, or an image that does not decode.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct IdentityError(pub String);
22
23impl std::fmt::Display for IdentityError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "request cannot be canonicalized: {}", self.0)
26    }
27}
28
29impl std::error::Error for IdentityError {}
30
31/// The RFC 8785 (JCS) canonical JSON bytes of `value`.
32pub fn canonical_json<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, IdentityError> {
33    serde_jcs::to_vec(value).map_err(|e| IdentityError(e.to_string()))
34}
35
36/// SHA-256 of the canonical JSON of `value`, as 64 lowercase hex characters.
37pub fn digest<T: Serialize + ?Sized>(value: &T) -> Result<String, IdentityError> {
38    let digest = Sha256::digest(canonical_json(value)?);
39    Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect())
40}
41
42/// The digest of one item: the item as JSON, with its `images` member, when
43/// present, replaced by each image's canonical form. `images` is the item's
44/// image list as the role decoded it.
45pub fn item_digest<T: Serialize + ?Sized>(
46    item: &T,
47    images: Option<&[Image]>,
48) -> Result<String, IdentityError> {
49    let mut value = serde_json::to_value(item).map_err(|e| IdentityError(e.to_string()))?;
50    if let (Some(images), Value::Object(members)) = (images, &mut value) {
51        let canonical = images
52            .iter()
53            .enumerate()
54            .map(|(j, image)| {
55                image
56                    .canonical()
57                    .map_err(|why| IdentityError(format!("images[{j}]: {why}")))
58            })
59            .collect::<Result<Vec<_>, _>>()?;
60        members.insert(
61            "images".into(),
62            serde_json::to_value(canonical).map_err(|e| IdentityError(e.to_string()))?,
63        );
64    }
65    digest(&value)
66}
67
68/// The first part in which `sent` differs from `recorded`, as
69/// `batch_id_reuse` names it. `parts` are the role's named parts in the
70/// order it checks them, each `(name, recorded, sent)`; then the item count
71/// (`items`) and the first differing item (`items[i]`). `None` when every
72/// part and item is the same, which makes the re-send a replay.
73pub fn first_difference(
74    parts: &[(&str, &str, &str)],
75    recorded_items: &[String],
76    sent_items: &[String],
77) -> Option<String> {
78    if let Some((name, _, _)) = parts.iter().find(|(_, recorded, sent)| recorded != sent) {
79        return Some((*name).to_owned());
80    }
81    if recorded_items.len() != sent_items.len() {
82        return Some("items".into());
83    }
84    recorded_items
85        .iter()
86        .zip(sent_items)
87        .position(|(recorded, sent)| recorded != sent)
88        .map(|i| format!("items[{i}]"))
89}
90
91#[cfg(test)]
92mod tests {
93    use serde_json::json;
94
95    use super::*;
96    use crate::image::InlineImage;
97
98    #[test]
99    fn number_spelling_and_key_order_do_not_change_a_digest() {
100        let float: Value = serde_json::from_str(r#"{"a":1.0,"b":{"y":[2.50],"x":0}}"#).unwrap();
101        let int: Value = serde_json::from_str(r#"{"b":{"x":0,"y":[2.5]},"a":1}"#).unwrap();
102        assert_ne!(
103            serde_json::to_string(&float).unwrap(),
104            serde_json::to_string(&int).unwrap()
105        );
106        assert_eq!(digest(&float).unwrap(), digest(&int).unwrap());
107    }
108
109    #[test]
110    fn an_image_in_either_spelling_is_the_same_item() {
111        let url = vec![Image::DataUrl("data:image/png;base64,AAECAw".into())];
112        let inline = vec![Image::Inline(InlineImage::new("image/png", "AAECAw=="))];
113        let a = json!({"state": "s", "images": url});
114        let b = json!({"state": "s", "images": inline});
115        assert_ne!(digest(&a).unwrap(), digest(&b).unwrap());
116        assert_eq!(
117            item_digest(&a, Some(&url)).unwrap(),
118            item_digest(&b, Some(&inline)).unwrap()
119        );
120        let other = vec![Image::Inline(InlineImage::new("image/jpeg", "AAECAw=="))];
121        let c = json!({"state": "s", "images": other});
122        assert_ne!(
123            item_digest(&a, Some(&url)).unwrap(),
124            item_digest(&c, Some(&other)).unwrap()
125        );
126    }
127
128    #[test]
129    fn the_first_differing_part_is_named_in_order() {
130        let items = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
131        let parts = [
132            ("model", "m", "m"),
133            ("fields", "f", "g"),
134            ("options", "o", "p"),
135        ];
136        assert_eq!(
137            first_difference(&parts, &items(&["a"]), &items(&["b"])),
138            Some("fields".into())
139        );
140        let same = [("model", "m", "m")];
141        assert_eq!(
142            first_difference(&same, &items(&["a", "b"]), &items(&["a", "c"])),
143            Some("items[1]".into())
144        );
145        assert_eq!(
146            first_difference(&same, &items(&["a"]), &items(&["a", "b"])),
147            Some("items".into())
148        );
149        assert_eq!(
150            first_difference(&same, &items(&["a"]), &items(&["a"])),
151            None
152        );
153    }
154}