1use sha2::{Digest as _, Sha256};
16
17pub const HEADER: &str = "Collection-Synchronization";
19
20#[derive(Clone, Debug, Default, Eq, PartialEq)]
22pub struct Digest([u8; 32]);
23
24impl Digest {
25 #[must_use]
27 pub fn new() -> Self {
28 Self::default()
29 }
30
31 #[must_use]
33 pub fn of<'a>(ids: impl IntoIterator<Item = &'a str>) -> Self {
34 let mut digest = Self::new();
35 for id in ids {
36 digest.add(id);
37 }
38 digest
39 }
40
41 pub fn add(&mut self, id: &str) {
43 let hash = Sha256::digest(id.as_bytes());
44 for (byte, other) in self.0.iter_mut().zip(hash.iter()) {
45 *byte ^= other;
46 }
47 }
48
49 #[must_use]
52 pub fn is_zero(&self) -> bool {
53 self.0.iter().all(|byte| *byte == 0)
54 }
55
56 #[must_use]
58 pub fn from_hex(hex: &str) -> Option<Self> {
59 if hex.len() != 64 {
60 return None;
61 }
62 let mut bytes = [0_u8; 32];
63 for (index, byte) in bytes.iter_mut().enumerate() {
64 *byte = u8::from_str_radix(hex.get(index * 2..index * 2 + 2)?, 16).ok()?;
65 }
66 Some(Self(bytes))
67 }
68
69 #[must_use]
71 pub fn to_hex(&self) -> String {
72 use std::fmt::Write as _;
73 self.0
74 .iter()
75 .fold(String::with_capacity(64), |mut hex, byte| {
76 let _ = write!(hex, "{byte:02x}");
77 hex
78 })
79 }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct CollectionSynchronization {
85 pub collection_id: String,
87 pub url: String,
89 pub digest: String,
91}
92
93impl CollectionSynchronization {
94 #[must_use]
96 pub fn to_header(&self) -> String {
97 format!(
98 "collectionId=\"{}\", digest=\"{}\", url=\"{}\"",
99 self.collection_id, self.digest, self.url
100 )
101 }
102
103 #[must_use]
107 pub fn parse(header: &str) -> Option<Self> {
108 let params = parameters(header.strip_prefix("Signature ").unwrap_or(header))?;
109 let get = |name: &str| {
110 params
111 .iter()
112 .find(|(key, _)| key == name)
113 .map(|(_, value)| value.clone())
114 };
115 Some(Self {
116 collection_id: get("collectionId")?,
117 url: get("url")?,
118 digest: get("digest")?,
119 })
120 }
121}
122
123fn is_token(c: char) -> bool {
124 c.is_ascii_alphanumeric() || "!#$%&'*+.^_`|~-".contains(c)
125}
126
127fn parameters(raw: &str) -> Option<Vec<(String, String)>> {
130 let mut params: Vec<(String, String)> = Vec::new();
131 let mut rest = raw;
132 loop {
133 let key_end = rest.find(|c: char| !is_token(c)).unwrap_or(rest.len());
134 if key_end == 0 {
135 return None;
136 }
137 let key = &rest[..key_end];
138 rest = rest[key_end..].trim_start().strip_prefix('=')?.trim_start();
139 let value;
140 if let Some(quoted) = rest.strip_prefix('"') {
141 let mut end = None;
142 let mut escaped = false;
143 for (index, c) in quoted.char_indices() {
144 match c {
145 _ if escaped => escaped = false,
146 '\\' => escaped = true,
147 '"' => {
148 end = Some(index);
149 break;
150 }
151 _ => {}
152 }
153 }
154 let end = end?;
155 value = quoted[..end].to_owned();
156 rest = "ed[end + 1..];
157 } else {
158 let end = rest.find(|c: char| !is_token(c)).unwrap_or(rest.len());
159 if end == 0 {
160 return None;
161 }
162 value = rest[..end].to_owned();
163 rest = &rest[end..];
164 }
165 if params.iter().any(|(existing, _)| existing == key) {
166 return None;
167 }
168 params.push((key.to_owned(), value));
169 rest = rest.trim_start();
170 if rest.is_empty() {
171 return Some(params);
172 }
173 rest = rest.strip_prefix(',')?.trim_start();
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn a_digest_is_the_xor_of_the_ids_hashes_in_any_order() {
183 let a = Digest::of(["https://a.test/users/1", "https://a.test/users/2"]);
184 let b = Digest::of(["https://a.test/users/2", "https://a.test/users/1"]);
185 assert_eq!(a, b);
186 assert!(Digest::new().is_zero());
187 assert_eq!(Digest::new().to_hex(), "0".repeat(64));
188 let mut folded = a.clone();
189 folded.add("https://a.test/users/1");
190 folded.add("https://a.test/users/2");
191 assert!(folded.is_zero());
192 assert_eq!(Digest::from_hex(&a.to_hex()), Some(a));
193 assert_eq!(Digest::from_hex("zz"), None);
194 }
195
196 #[test]
197 fn the_digest_of_one_id_is_its_sha256() {
198 assert_eq!(
200 Digest::of(["a"]).to_hex(),
201 "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb"
202 );
203 }
204
205 #[test]
206 fn a_header_is_written_and_read_as_mastodon_does() {
207 let header = CollectionSynchronization {
208 collection_id: "https://a.test/users/alice/followers".into(),
209 url: "https://a.test/users/alice/followers_synchronization".into(),
210 digest: "ab".repeat(32),
211 };
212 let written = header.to_header();
213 assert_eq!(
214 written,
215 format!(
216 "collectionId=\"https://a.test/users/alice/followers\", digest=\"{}\", \
217 url=\"https://a.test/users/alice/followers_synchronization\"",
218 "ab".repeat(32)
219 )
220 );
221 assert_eq!(CollectionSynchronization::parse(&written), Some(header));
222 assert_eq!(
223 CollectionSynchronization::parse("collectionId=\"x\", url=\"y\""),
224 None,
225 "a digest is needed"
226 );
227 assert_eq!(
228 CollectionSynchronization::parse("collectionId=\"x\", collectionId=\"x\""),
229 None
230 );
231 assert_eq!(CollectionSynchronization::parse("nonsense"), None);
232 }
233}