hashiverse-lib 1.0.6

Core protocol library for Hashiverse — your open-source decentralized X/Twitter replacement.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! # Meta-post data model: per-account profile & config
//!
//! A "meta-post" is the one post per account per month that carries the user's profile
//! and client-side config rather than a conversational payload. The public section
//! (nickname, status, avatar, follows) is readable by everyone on the network; the
//! private section (per-feedback-type thresholds, skip-warnings flags, anything we'd
//! rather keep account-local) is encrypted so only the owner can read it — see
//! [`crate::client::meta_post::meta_post_crypto`].
//!
//! Each field is wrapped in `VersionedField<T>` with a monotonic timestamp, giving the
//! whole document last-writer-wins CRDT semantics at field granularity. Two devices can
//! edit the profile concurrently and converge without either losing the other's changes
//! to unrelated fields.
//!
//! The [`MetaPost`] enum wraps the versioned variants (currently just `V1`) so that a
//! post found on a User bucket can be parsed back into a meta-post whenever the contents
//! match.

use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::tools::json;
use crate::tools::time::{TimeMillis, MILLIS_IN_MONTH};
use crate::tools::types::Salt;

// ---------------------------------------------------------------------------
// VersionedField — generic per-field/per-item CRDT with last-writer-wins
// ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct VersionedField<T> {
    /// The current value, or `None` if this field/item has been deleted (tombstone).
    pub value: Option<T>,
    /// Millisecond timestamp of the last write.  Higher wins on merge.
    pub timestamp: TimeMillis,
}

impl<T> VersionedField<T> {
    pub fn new(value: T, timestamp: TimeMillis) -> Self {
        Self { value: Some(value), timestamp }
    }

    pub fn tombstone(timestamp: TimeMillis) -> Self {
        Self { value: None, timestamp }
    }

    pub fn is_tombstone(&self) -> bool {
        self.value.is_none()
    }
}

// ---------------------------------------------------------------------------
// MetaPost envelope (parsed from the JSON `meta` field)
// ---------------------------------------------------------------------------

#[derive(Debug, PartialEq)]
#[allow(clippy::large_enum_variant)] // V1 dominates by design — adding a Box would just rename indirection that's rarely on a hot path
pub enum MetaPost {
    None,
    MetaPostV1(MetaPostV1),
}

impl MetaPost {
    pub fn try_parse_meta_post(post: &str) -> anyhow::Result<MetaPost> {
        let post = post.as_bytes();
        let meta = json::bytes_to_struct::<MetaOnly>(post);
        match meta {
            Err(_) => Ok(Self::None),
            Ok(meta) => match meta.meta.as_str() {
                "MetaPostV1" => Ok(Self::MetaPostV1(json::bytes_to_struct(post)?)),
                meta => anyhow::bail!("Unsupported meta type: {}", meta),
            },
        }
    }
}

#[derive(Deserialize)]
struct MetaOnly {
    meta: String,
}

// ---------------------------------------------------------------------------
// MetaPostV1 — the unified config meta-post
// ---------------------------------------------------------------------------

const META_POST_V1_META: &str = "MetaPostV1";

#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct MetaPostV1 {
    meta: String,
    pub client_id: String,
    pub encryption_salt: Salt,
    pub public: MetaPostPublicV1,
    /// Hex-encoded, strongly-encrypted blob containing a serialised
    /// `MetaPostPrivateV1`.  Other users cannot decrypt this.
    pub private_encrypted: String,
}

impl MetaPostV1 {
    pub fn new(client_id: String, encryption_salt: Salt, public: MetaPostPublicV1, private_encrypted: String) -> Self {
        Self {
            meta: META_POST_V1_META.to_string(),
            client_id,
            encryption_salt,
            public,
            private_encrypted,
        }
    }
}

// ---------------------------------------------------------------------------
// Public section — readable by everyone (bio data)
// ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct MetaPostPublicV1 {
    pub nickname: VersionedField<String>,
    pub status: VersionedField<String>,
    pub selfie: VersionedField<String>,
    pub avatar: VersionedField<String>,
}

impl MetaPostPublicV1 {
    pub fn empty() -> Self {
        Self {
            nickname: VersionedField::tombstone(TimeMillis::zero()),
            status: VersionedField::tombstone(TimeMillis::zero()),
            selfie: VersionedField::tombstone(TimeMillis::zero()),
            avatar: VersionedField::tombstone(TimeMillis::zero()),
        }
    }

    pub fn from_bio(timestamp: TimeMillis, nickname: String, status: String, selfie: String, avatar: String) -> Self {
        Self {
            nickname: VersionedField::new(nickname, timestamp),
            status: VersionedField::new(status, timestamp),
            selfie: VersionedField::new(selfie, timestamp),
            avatar: VersionedField::new(avatar, timestamp),
        }
    }

    /// The highest timestamp across all public fields — useful for
    /// deciding whether an incoming bio is newer than a cached one.
    pub fn max_timestamp(&self) -> TimeMillis {
        [self.nickname.timestamp, self.status.timestamp, self.selfie.timestamp, self.avatar.timestamp]
            .into_iter()
            .max()
            .unwrap_or(TimeMillis::zero())
    }
}

// ---------------------------------------------------------------------------
// Private section — encrypted, only the owning client can read
// ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct MetaPostPrivateV1 {
    /// Key = user-id hex string.  Value = `true` means followed, `None` = unfollowed tombstone.
    pub followed_client_ids: HashMap<String, VersionedField<bool>>,
    /// Key = hashtag string.
    pub followed_hashtags: HashMap<String, VersionedField<bool>>,
    /// Key = feedback_type (0-255).  Value = threshold count.
    pub content_thresholds: HashMap<u8, VersionedField<u32>>,
    pub skip_warnings_for_followed: VersionedField<bool>,
}

impl MetaPostPrivateV1 {
    pub fn empty() -> Self {
        Self {
            followed_client_ids: HashMap::new(),
            followed_hashtags: HashMap::new(),
            content_thresholds: HashMap::new(),
            skip_warnings_for_followed: VersionedField::tombstone(TimeMillis::zero()),
        }
    }
}

// ---------------------------------------------------------------------------
// Merge helpers
// ---------------------------------------------------------------------------

use crate::tools::time::DurationMillis;

const TOMBSTONE_MAX_AGE: DurationMillis = MILLIS_IN_MONTH.const_mul(3);

/// Merge a single scalar versioned field: last-writer-wins.
pub fn merge_scalar_field<T: Clone>(local: &VersionedField<T>, incoming: &VersionedField<T>) -> VersionedField<T> {
    if incoming.timestamp > local.timestamp {
        incoming.clone()
    } else {
        local.clone()
    }
}

/// Merge a versioned map (collection field): per-key last-writer-wins,
/// with tombstone garbage collection for entries older than 6 months.
pub fn merge_collection<K: Clone + Eq + std::hash::Hash, T: Clone>(local: &HashMap<K, VersionedField<T>>, incoming: &HashMap<K, VersionedField<T>>, now_millis: TimeMillis) -> HashMap<K, VersionedField<T>> {
    let mut merged: HashMap<K, VersionedField<T>> = local.clone();

    for (key, incoming_field) in incoming {
        match merged.get(key) {
            Some(local_field) => {
                if incoming_field.timestamp > local_field.timestamp {
                    merged.insert(key.clone(), incoming_field.clone());
                }
            }
            None => {
                merged.insert(key.clone(), incoming_field.clone());
            }
        }
    }

    // Garbage-collect old tombstones
    let cutoff = now_millis.saturating_sub_duration(TOMBSTONE_MAX_AGE);
    merged.retain(|_key, field| {
        !(field.is_tombstone() && field.timestamp < cutoff)
    });

    merged
}

/// Merge two full public sections.
pub fn merge_public(local: &MetaPostPublicV1, incoming: &MetaPostPublicV1) -> MetaPostPublicV1 {
    MetaPostPublicV1 {
        nickname: merge_scalar_field(&local.nickname, &incoming.nickname),
        status: merge_scalar_field(&local.status, &incoming.status),
        selfie: merge_scalar_field(&local.selfie, &incoming.selfie),
        avatar: merge_scalar_field(&local.avatar, &incoming.avatar),
    }
}

/// Merge two full private sections.
pub fn merge_private(local: &MetaPostPrivateV1, incoming: &MetaPostPrivateV1, now_millis: TimeMillis) -> MetaPostPrivateV1 {
    MetaPostPrivateV1 {
        followed_client_ids: merge_collection(&local.followed_client_ids, &incoming.followed_client_ids, now_millis),
        followed_hashtags: merge_collection(&local.followed_hashtags, &incoming.followed_hashtags, now_millis),
        content_thresholds: merge_collection(&local.content_thresholds, &incoming.content_thresholds, now_millis),
        skip_warnings_for_followed: merge_scalar_field(&local.skip_warnings_for_followed, &incoming.skip_warnings_for_followed),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::json;
    use crate::tools::types::Id;

    fn t(millis: i64) -> TimeMillis { TimeMillis(millis) }

    #[test]
    fn meta_post_v1_roundtrip() -> anyhow::Result<()> {
        let public = MetaPostPublicV1 {
            nickname: VersionedField::new("alice".to_string(), t(100)),
            status: VersionedField::new("hello world".to_string(), t(100)),
            selfie: VersionedField::new("base64data".to_string(), t(100)),
            avatar: VersionedField::new("seed123".to_string(), t(100)),
        };

        let meta_post = MetaPostV1::new(
            Id::random().to_hex_str(),
            Salt::random(),
            public,
            "deadbeef".to_string(),
        );

        let post_bytes = json::struct_to_bytes(&meta_post)?;
        let post_str = std::str::from_utf8(&post_bytes)?;

        let parsed = MetaPost::try_parse_meta_post(post_str)?;
        match parsed {
            MetaPost::MetaPostV1(restored) => assert_eq!(restored, meta_post),
            other => anyhow::bail!("Expected MetaPostV1, got {:?}", other),
        }

        Ok(())
    }

    #[test]
    fn normal_posts_return_none() -> anyhow::Result<()> {
        let post = "This is a simple post";
        assert_eq!(MetaPost::None, MetaPost::try_parse_meta_post(post)?);
        Ok(())
    }

    #[test]
    fn malformed_json_is_just_like_a_normal_post() -> anyhow::Result<()> {
        let post = r#"{"meta":"MetaPostV1", the rest is garbage"#;
        assert_eq!(MetaPost::None, MetaPost::try_parse_meta_post(post)?);
        Ok(())
    }

    #[test]
    fn well_formed_but_incomplete_json_is_an_error() -> anyhow::Result<()> {
        let post = r#"{"meta":"MetaPostV1", "name": "terry pratt" }"#;
        assert!(MetaPost::try_parse_meta_post(post).is_err());
        Ok(())
    }

    #[test]
    fn unknown_meta_is_an_error() -> anyhow::Result<()> {
        let post = r#"{"meta":"WhackoV123", "name": "terry pratt" }"#;
        assert!(MetaPost::try_parse_meta_post(post).is_err());
        Ok(())
    }

    // -----------------------------------------------------------------------
    // Merge tests
    // -----------------------------------------------------------------------

    #[test]
    fn merge_scalar_field_higher_timestamp_wins() {
        let local = VersionedField::new("old".to_string(), t(100));
        let incoming = VersionedField::new("new".to_string(), t(200));
        let merged = merge_scalar_field(&local, &incoming);
        assert_eq!(merged.value, Some("new".to_string()));
        assert_eq!(merged.timestamp, t(200));
    }

    #[test]
    fn merge_scalar_field_local_wins_on_tie() {
        let local = VersionedField::new("local".to_string(), t(100));
        let incoming = VersionedField::new("incoming".to_string(), t(100));
        let merged = merge_scalar_field(&local, &incoming);
        assert_eq!(merged.value, Some("local".to_string()));
    }

    #[test]
    fn merge_scalar_field_tombstone_wins_if_newer() {
        let local = VersionedField::new("alive".to_string(), t(100));
        let incoming: VersionedField<String> = VersionedField::tombstone(t(200));
        let merged = merge_scalar_field(&local, &incoming);
        assert!(merged.is_tombstone());
        assert_eq!(merged.timestamp, t(200));
    }

    #[test]
    fn merge_collection_combines_disjoint_keys() {
        let mut local: HashMap<String, VersionedField<bool>> = HashMap::new();
        local.insert("a".to_string(), VersionedField::new(true, t(100)));

        let mut incoming: HashMap<String, VersionedField<bool>> = HashMap::new();
        incoming.insert("b".to_string(), VersionedField::new(true, t(200)));

        let merged = merge_collection(&local, &incoming, t(300));
        assert_eq!(merged.len(), 2);
        assert!(merged.contains_key("a"));
        assert!(merged.contains_key("b"));
    }

    #[test]
    fn merge_collection_higher_timestamp_wins_per_key() {
        let mut local: HashMap<String, VersionedField<bool>> = HashMap::new();
        local.insert("x".to_string(), VersionedField::new(true, t(100)));

        let mut incoming: HashMap<String, VersionedField<bool>> = HashMap::new();
        incoming.insert("x".to_string(), VersionedField::tombstone(t(200)));

        let merged = merge_collection(&local, &incoming, t(300));
        assert!(merged["x"].is_tombstone());
        assert_eq!(merged["x"].timestamp, t(200));
    }

    #[test]
    fn merge_collection_garbage_collects_old_tombstones() {
        let now = t(TOMBSTONE_MAX_AGE.0 * 2); // well past the GC window
        let seven_months_ago = TimeMillis(now.0 - TOMBSTONE_MAX_AGE.0 - 1);

        let mut local: HashMap<String, VersionedField<bool>> = HashMap::new();
        local.insert("old_deleted".to_string(), VersionedField::tombstone(seven_months_ago));
        local.insert("recent_deleted".to_string(), VersionedField::tombstone(TimeMillis(now.0 - 1)));
        local.insert("alive".to_string(), VersionedField::new(true, TimeMillis(now.0 - 500)));

        let incoming: HashMap<String, VersionedField<bool>> = HashMap::new();

        let merged = merge_collection(&local, &incoming, now);
        assert!(!merged.contains_key("old_deleted"), "Old tombstone should be garbage collected");
        assert!(merged.contains_key("recent_deleted"), "Recent tombstone should be kept");
        assert!(merged.contains_key("alive"), "Alive entry should be kept");
    }

    #[test]
    fn merge_public_picks_newer_fields() {
        let local = MetaPostPublicV1 {
            nickname: VersionedField::new("old_nick".to_string(), t(100)),
            status: VersionedField::new("old_status".to_string(), t(200)),
            selfie: VersionedField::new("old_selfie".to_string(), t(300)),
            avatar: VersionedField::new("old_avatar".to_string(), t(400)),
        };

        let incoming = MetaPostPublicV1 {
            nickname: VersionedField::new("new_nick".to_string(), t(200)),   // newer
            status: VersionedField::new("new_status".to_string(), t(100)),   // older
            selfie: VersionedField::new("new_selfie".to_string(), t(300)),   // tie -> local wins
            avatar: VersionedField::new("new_avatar".to_string(), t(500)),   // newer
        };

        let merged = merge_public(&local, &incoming);
        assert_eq!(merged.nickname.value, Some("new_nick".to_string()));
        assert_eq!(merged.status.value, Some("old_status".to_string()));
        assert_eq!(merged.selfie.value, Some("old_selfie".to_string()));
        assert_eq!(merged.avatar.value, Some("new_avatar".to_string()));
    }

    #[test]
    fn merge_private_merges_all_sections() {
        let mut local = MetaPostPrivateV1::empty();
        local.followed_client_ids.insert("client_a".to_string(), VersionedField::new(true, t(100)));
        local.skip_warnings_for_followed = VersionedField::new(false, t(100));

        let mut incoming = MetaPostPrivateV1::empty();
        incoming.followed_client_ids.insert("client_b".to_string(), VersionedField::new(true, t(200)));
        incoming.skip_warnings_for_followed = VersionedField::new(true, t(200));

        let merged = merge_private(&local, &incoming, t(300));
        assert_eq!(merged.followed_client_ids.len(), 2);
        assert!(merged.followed_client_ids.contains_key("client_a"));
        assert!(merged.followed_client_ids.contains_key("client_b"));
        assert_eq!(merged.skip_warnings_for_followed.value, Some(true));
    }

    #[test]
    fn private_v1_roundtrip() -> anyhow::Result<()> {
        let mut private = MetaPostPrivateV1::empty();
        private.followed_client_ids.insert("abc123".to_string(), VersionedField::new(true, t(100)));
        private.followed_client_ids.insert("def456".to_string(), VersionedField::tombstone(t(200)));
        private.followed_hashtags.insert("rust".to_string(), VersionedField::new(true, t(150)));
        private.content_thresholds.insert(101, VersionedField::new(100, t(300)));
        private.skip_warnings_for_followed = VersionedField::new(true, t(400));

        let bytes = json::struct_to_bytes(&private)?;
        let restored: MetaPostPrivateV1 = json::bytes_to_struct(&bytes)?;
        assert_eq!(private, restored);
        Ok(())
    }
}