Skip to main content

ojak/
synchronization.rs

1//! Followers collection synchronization ([FEP-8fcf]): the digest of the
2//! followers a server holds for an actor, and the `Collection-Synchronization`
3//! header that carries it on a delivery, so that the receiving server can
4//! tell when its idea of who follows the actor there has drifted.
5//!
6//! The digest of a set of actor IDs is the XOR of their SHA-256 hashes,
7//! written in lowercase hex: it is the same whatever order the IDs are
8//! taken in, and the empty set's is all zeros. The header names the
9//! collection the digest is of, the digest, and where the followers on the
10//! receiving server are listed, in the syntax of an HTTP signature's
11//! parameters, as Mastodon writes and reads it.
12//!
13//! [FEP-8fcf]: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
14
15use sha2::{Digest as _, Sha256};
16
17/// The header a delivery carries it in.
18pub const HEADER: &str = "Collection-Synchronization";
19
20/// A digest of actor IDs, being built.
21#[derive(Clone, Debug, Default, Eq, PartialEq)]
22pub struct Digest([u8; 32]);
23
24impl Digest {
25    /// The empty set's: all zeros.
26    #[must_use]
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// The digest of `ids`.
32    #[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    /// Fold `id` in. Folding the same ID in twice takes it out again.
42    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    /// Whether it is all zeros: the empty set's, or two equal sets' folded
50    /// together.
51    #[must_use]
52    pub fn is_zero(&self) -> bool {
53        self.0.iter().all(|byte| *byte == 0)
54    }
55
56    /// Read a digest written in hex; `None` when it is not 32 bytes of hex.
57    #[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    /// Its lowercase hex.
70    #[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/// What a `Collection-Synchronization` header says.
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct CollectionSynchronization {
85    /// The followers collection the digest is of.
86    pub collection_id: String,
87    /// The followers on the receiving server, as a partial collection.
88    pub url: String,
89    /// The digest of the followers on the receiving server, in hex.
90    pub digest: String,
91}
92
93impl CollectionSynchronization {
94    /// The header's value: `collectionId="…", digest="…", url="…"`.
95    #[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    /// Read a header's value, as the parameters of an HTTP signature are
104    /// read: `key=value` or `key="value"`, separated by commas, each key
105    /// once. `None` when it does not parse or lacks one of the three.
106    #[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
127/// `SignatureParser.parse`: the parameters, or `None` when they do not
128/// parse or a key is repeated.
129fn 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 = &quoted[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        // SHA-256 of "a".
199        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}