max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+1439 insertions,
-84 deletions
| @@ -57,11 +57,12 @@ | |||
| 57 | 57 | /// [`gated_at`](Self::gated_at) to pin `now` across a whole sync batch (or in | |
| 58 | 58 | /// tests) so every change in the batch is classified against the same instant. | |
| 59 | 59 | pub fn gated(self, committed_hlc: impl Fn(&str, &str) -> Option<Hlc>) -> Vec<ChangeEntry> { | |
| 60 | - | self.gated_at(Utc::now(), committed_hlc) | |
| 60 | + | self.gated_at(Utc::now(), committed_hlc, |_, _| {}) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 63 | /// [`gated`](Self::gated) with an explicit `now`, so the clock-poisoning | |
| 64 | - | /// classification is deterministic (testable) and consistent across a batch. | |
| 64 | + | /// classification is deterministic (testable) and consistent across a batch, | |
| 65 | + | /// and a sink for what the committed-HLC gate discards. | |
| 65 | 66 | /// | |
| 66 | 67 | /// The poisoning threshold is still fundamentally wall-clock-relative: two | |
| 67 | 68 | /// devices whose clocks straddle the drift boundary by less than their skew | |
| @@ -69,38 +70,48 @@ | |||
| 69 | 70 | /// removes the *within-device* drift between the batch's first and last change; | |
| 70 | 71 | /// the residual cross-device boundary window is the bounded, documented | |
| 71 | 72 | /// tradeoff of [`MAX_HLC_DRIFT_MS`] (see [`resolve_lww_at`]). | |
| 73 | + | /// | |
| 74 | + | /// `on_superseded(dropped, committed)` fires for each change the committed-HLC | |
| 75 | + | /// gate rejects: a remote edit older than what this device already applied and | |
| 76 | + | /// pushed. That is a lost edit exactly as much as a resolved conflict is, but | |
| 77 | + | /// it never becomes a [`ConflictPair`] and so is invisible to | |
| 78 | + | /// [`resolve_lww`], which is what made it the easiest kind of data loss to | |
| 79 | + | /// miss. A **clock-poisoned** drop deliberately does not fire it: that entry | |
| 80 | + | /// is rejected as hostile or broken, not outvoted, and stashing it would | |
| 81 | + | /// hand an attacker a way to fill the stash. The function stays pure; the | |
| 82 | + | /// caller decides what persistence means. | |
| 72 | 83 | pub fn gated_at( | |
| 73 | 84 | self, | |
| 74 | 85 | now: DateTime<Utc>, | |
| 75 | 86 | committed_hlc: impl Fn(&str, &str) -> Option<Hlc>, | |
| 87 | + | mut on_superseded: impl FnMut(&PulledChange, &Hlc), | |
| 76 | 88 | ) -> Vec<ChangeEntry> { | |
| 77 | - | self.0 | |
| 78 | - | .into_iter() | |
| 79 | - | .filter(|p| { | |
| 80 | - | // A clean change is applied without passing through `resolve_lww`, | |
| 81 | - | // so the clock-poisoning guard must also live here: never apply an | |
| 82 | - | // entry whose wall clock is implausibly far in the future. | |
| 83 | - | if is_clock_poisoned(&p.entry.hlc, now) { | |
| 84 | - | tracing::warn!( | |
| 85 | - | table = %p.entry.table, | |
| 86 | - | wall_ms = p.entry.hlc.wall_ms, | |
| 87 | - | "dropping clean change with implausibly-future HLC" | |
| 88 | - | ); | |
| 89 | - | return false; | |
| 90 | - | } | |
| 91 | - | let apply = committed_hlc(&p.entry.table, &p.entry.row_id) | |
| 92 | - | .is_none_or(|committed| p.entry.hlc > committed); | |
| 93 | - | if !apply { | |
| 89 | + | let mut kept = Vec::with_capacity(self.0.len()); | |
| 90 | + | for p in self.0 { | |
| 91 | + | // A clean change is applied without passing through `resolve_lww`, | |
| 92 | + | // so the clock-poisoning guard must also live here: never apply an | |
| 93 | + | // entry whose wall clock is implausibly far in the future. | |
| 94 | + | if is_clock_poisoned(&p.entry.hlc, now) { | |
| 95 | + | tracing::warn!( | |
| 96 | + | table = %p.entry.table, | |
| 97 | + | wall_ms = p.entry.hlc.wall_ms, | |
| 98 | + | "dropping clean change with implausibly-future HLC" | |
| 99 | + | ); | |
| 100 | + | continue; | |
| 101 | + | } | |
| 102 | + | match committed_hlc(&p.entry.table, &p.entry.row_id) { | |
| 103 | + | Some(committed) if p.entry.hlc <= committed => { | |
| 94 | 104 | tracing::debug!( | |
| 95 | 105 | table = %p.entry.table, | |
| 96 | 106 | row_id = %p.entry.row_id, | |
| 97 | 107 | "clean change gated out: older than the committed HLC" | |
| 98 | 108 | ); | |
| 109 | + | on_superseded(&p, &committed); | |
| 99 | 110 | } | |
| 100 | - | apply | |
| 101 | - | }) | |
| 102 | - | .map(|p| p.entry) | |
| 103 | - | .collect() | |
| 111 | + | _ => kept.push(p.entry), | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | kept | |
| 104 | 115 | } | |
| 105 | 116 | ||
| 106 | 117 | /// The `(table, row_id)` addresses of the clean changes, so a caller can | |
| @@ -359,7 +370,7 @@ | |||
| 359 | 370 | ||
| 360 | 371 | /// [`canonical_value`] for an optional payload; `None` (a delete) canonicalizes | |
| 361 | 372 | /// to empty bytes. | |
| 362 | - | fn canonical_payload(data: Option<&serde_json::Value>) -> Vec<u8> { | |
| 373 | + | pub(crate) fn canonical_payload(data: Option<&serde_json::Value>) -> Vec<u8> { | |
| 363 | 374 | data.map(canonical_value).unwrap_or_default() | |
| 364 | 375 | } | |
| 365 | 376 |
| @@ -268,6 +268,18 @@ | |||
| 268 | 268 | pub created_at: DateTime<Utc>, | |
| 269 | 269 | } | |
| 270 | 270 | ||
| 271 | + | /// A member's identity public key, as returned by | |
| 272 | + | /// [`SyncKitClient::list_member_pubkeys`](crate::SyncKitClient::list_member_pubkeys). | |
| 273 | + | /// The admin seals a rotated GCK to each of these. | |
| 274 | + | #[derive(Debug, Clone, Deserialize)] | |
| 275 | + | #[non_exhaustive] | |
| 276 | + | pub struct GroupMemberPubkey { | |
| 277 | + | /// The member's account id. | |
| 278 | + | pub user_id: UserId, | |
| 279 | + | /// Their X25519 identity public key, base64. | |
| 280 | + | pub pubkey: String, | |
| 281 | + | } | |
| 282 | + | ||
| 271 | 283 | /// A member of a group, as returned by | |
| 272 | 284 | /// [`SyncKitClient::list_members`](crate::SyncKitClient::list_members). Grants are | |
| 273 | 285 | /// not included, each member fetches only their own. | |
| @@ -403,6 +415,14 @@ | |||
| 403 | 415 | /// Which encryption key was used. None means key_id 1 (pre-rotation). | |
| 404 | 416 | #[serde(default)] | |
| 405 | 417 | pub key_id: Option<i32>, | |
| 418 | + | /// For a group entry, the GCK generation its ciphertext is sealed under. A | |
| 419 | + | /// rotation bumps the group's generation without re-encrypting what came | |
| 420 | + | /// before, so a pull can span generations and each row says which key opens | |
| 421 | + | /// it. `None` on personal entries, and on group entries from a server | |
| 422 | + | /// predating per-generation grants, where the caller falls back to the | |
| 423 | + | /// group's current generation. | |
| 424 | + | #[serde(default)] | |
| 425 | + | pub gck_version: Option<i32>, | |
| 406 | 426 | } | |
| 407 | 427 | ||
| 408 | 428 | // ── Filtered pull ── |
| @@ -3658,3 +3658,544 @@ | |||
| 3658 | 3658 | std::fs::remove_file(&file).ok(); | |
| 3659 | 3659 | } | |
| 3660 | 3660 | } | |
| 3661 | + | ||
| 3662 | + | // ── Group key rotation ── | |
| 3663 | + | ||
| 3664 | + | /// Rotation is the removal primitive: the batch an admin posts becomes the new | |
| 3665 | + | /// membership, so the server drops anyone absent from it and re-keys in the same | |
| 3666 | + | /// transaction. These tests pin the batch the client builds, because everything | |
| 3667 | + | /// the server can enforce depends on the client getting that batch right. | |
| 3668 | + | mod group_rotation { | |
| 3669 | + | use super::*; | |
| 3670 | + | use synckit_client::{ | |
| 3671 | + | GroupId, IdentityKeypair, IdentityPublicKey, generate_group_key, open_gck_grant, | |
| 3672 | + | seal_gck_to_member, | |
| 3673 | + | }; | |
| 3674 | + | use wiremock::matchers::path_regex; | |
| 3675 | + | ||
| 3676 | + | /// A member we control both halves of, so a grant sealed to them can be | |
| 3677 | + | /// opened and checked rather than merely counted. | |
| 3678 | + | struct Member { | |
| 3679 | + | user_id: UserId, | |
| 3680 | + | keypair: IdentityKeypair, | |
| 3681 | + | } | |
| 3682 | + | ||
| 3683 | + | impl Member { | |
| 3684 | + | fn new() -> Self { | |
| 3685 | + | Self { | |
| 3686 | + | user_id: UserId::new(Uuid::new_v4()), | |
| 3687 | + | keypair: IdentityKeypair::generate(), | |
| 3688 | + | } | |
| 3689 | + | } | |
| 3690 | + | ||
| 3691 | + | fn pubkey_json(&self) -> serde_json::Value { | |
| 3692 | + | json!({ | |
| 3693 | + | "user_id": self.user_id, | |
| 3694 | + | "pubkey": self.keypair.public_key().to_base64(), | |
| 3695 | + | }) | |
| 3696 | + | } | |
| 3697 | + | } | |
| 3698 | + | ||
| 3699 | + | /// The client whose master key seeds the admin identity, plus that identity. | |
| 3700 | + | fn admin_client(server: &MockServer) -> (SyncKitClient, IdentityKeypair) { | |
| 3701 | + | let client = authed_client(server); | |
| 3702 | + | let master = synckit_client::crypto::generate_master_key(); | |
| 3703 | + | let identity = IdentityKeypair::from_master_key(&master); | |
| 3704 | + | client.set_master_key_raw(master); | |
| 3705 | + | (client, identity) | |
| 3706 | + | } | |
| 3707 | + | ||
| 3708 | + | /// Mount the two reads a rotation makes: the admin's own grant (for the | |
| 3709 | + | /// current generation) and the member pubkey list (the re-seal inputs). | |
| 3710 | + | async fn mount_reads( | |
| 3711 | + | server: &MockServer, | |
| 3712 | + | group_id: GroupId, | |
| 3713 | + | gck: &[u8; 32], | |
| 3714 | + | admin: &IdentityKeypair, | |
| 3715 | + | admin_id: UserId, | |
| 3716 | + | version: i32, | |
| 3717 | + | members: &[&Member], | |
| 3718 | + | ) { | |
| 3719 | + | let sealed = seal_gck_to_member(gck, &admin.public_key(), &group_id.to_string(), version) | |
| 3720 | + | .expect("seal admin grant"); | |
| 3721 | + | Mock::given(method("GET")) | |
| 3722 | + | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 3723 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3724 | + | "sealed_gck": sealed, | |
| 3725 | + | "gck_version": version, | |
| 3726 | + | }))) | |
| 3727 | + | .mount(server) | |
| 3728 | + | .await; | |
| 3729 | + | ||
| 3730 | + | let mut pubkeys = vec![json!({ | |
| 3731 | + | "user_id": admin_id, | |
| 3732 | + | "pubkey": admin.public_key().to_base64(), | |
| 3733 | + | })]; | |
| 3734 | + | pubkeys.extend(members.iter().map(|m| m.pubkey_json())); | |
| 3735 | + | Mock::given(method("GET")) | |
| 3736 | + | .and(path(format!("/api/v1/sync/groups/{group_id}/pubkeys"))) | |
| 3737 | + | .respond_with(ResponseTemplate::new(200).set_body_json(pubkeys)) | |
| 3738 | + | .mount(server) | |
| 3739 | + | .await; | |
| 3740 | + | } | |
| 3741 | + | ||
| 3742 | + | async fn mount_rotate(server: &MockServer) { | |
| 3743 | + | Mock::given(method("POST")) | |
| 3744 | + | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/rotate$")) | |
| 3745 | + | .respond_with(ResponseTemplate::new(204)) | |
| 3746 | + | .mount(server) | |
| 3747 | + | .await; | |
| 3748 | + | } | |
| 3749 | + | ||
| 3750 | + | /// The body the client POSTed to `/rotate`. | |
| 3751 | + | async fn posted_batch(server: &MockServer) -> serde_json::Value { | |
| 3752 | + | let reqs = server.received_requests().await.expect("requests"); | |
| 3753 | + | let rotate = reqs | |
| 3754 | + | .iter() | |
| 3755 | + | .find(|r| r.url.path().ends_with("/rotate")) | |
| 3756 | + | .expect("a rotate request was sent"); | |
| 3757 | + | serde_json::from_slice(&rotate.body).expect("rotate body is JSON") | |
| 3758 | + | } | |
| 3759 | + | ||
| 3760 | + | #[tokio::test] | |
| 3761 | + | async fn removing_a_member_rekeys_and_reseals_to_everyone_who_stays() { | |
| 3762 | + | let server = MockServer::start().await; | |
| 3763 | + | let (client, admin_identity) = admin_client(&server); | |
| 3764 | + | let (admin_id, _) = test_ids(); | |
| 3765 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 3766 | + | let old_gck = generate_group_key(); | |
| 3767 | + | ||
| 3768 | + | let bob = Member::new(); | |
| 3769 | + | let carol = Member::new(); | |
| 3770 | + | mount_reads( | |
| 3771 | + | &server, | |
| 3772 | + | group_id, | |
| 3773 | + | &old_gck, | |
| 3774 | + | &admin_identity, | |
| 3775 | + | admin_id, | |
| 3776 | + | 7, | |
| 3777 | + | &[&bob, &carol], | |
| 3778 | + | ) | |
| 3779 | + | .await; | |
| 3780 | + | mount_rotate(&server).await; | |
| 3781 | + | ||
| 3782 | + | client | |
| 3783 | + | .remove_member(group_id, carol.user_id) | |
| 3784 | + | .await | |
| 3785 | + | .expect("remove member"); | |
| 3786 | + | ||
| 3787 | + | let batch = posted_batch(&server).await; | |
| 3788 | + | assert_eq!( | |
| 3789 | + | batch["gck_version"], 8, | |
| 3790 | + | "the generation must advance past the one our grant reports" | |
| 3791 | + | ); | |
| 3792 | + | ||
| 3793 | + | let grants = batch["grants"].as_array().expect("grants array"); | |
| 3794 | + | assert_eq!(grants.len(), 2, "admin and bob, not carol: {grants:?}"); | |
| 3795 | + | let recipients: Vec<&str> = grants | |
| 3796 | + | .iter() | |
| 3797 | + | .map(|g| g["user_id"].as_str().expect("user_id")) | |
| 3798 | + | .collect(); | |
| 3799 | + | assert!(recipients.contains(&admin_id.to_string().as_str())); | |
| 3800 | + | assert!(recipients.contains(&bob.user_id.to_string().as_str())); | |
| 3801 | + | assert!( | |
| 3802 | + | !recipients.contains(&carol.user_id.to_string().as_str()), | |
| 3803 | + | "the removed member must not be re-granted" | |
| 3804 | + | ); | |
| 3805 | + | ||
| 3806 | + | // The grants are real seals of one new key, not placeholders: Bob's opens, | |
| 3807 | + | // and what comes out is neither the old GCK nor something private to the | |
| 3808 | + | // admin's copy. | |
| 3809 | + | let bobs = grants | |
| 3810 | + | .iter() | |
| 3811 | + | .find(|g| g["user_id"].as_str() == Some(&bob.user_id.to_string())) | |
| 3812 | + | .expect("bob's grant"); | |
| 3813 | + | let new_gck = open_gck_grant( | |
| 3814 | + | bobs["sealed_gck"].as_str().expect("sealed_gck"), | |
| 3815 | + | &bob.keypair, | |
| 3816 | + | &group_id.to_string(), | |
| 3817 | + | 8, | |
| 3818 | + | ) | |
| 3819 | + | .expect("bob opens his grant"); | |
| 3820 | + | assert_ne!(new_gck, old_gck, "rotation must mint a fresh key"); | |
| 3821 | + | ||
| 3822 | + | let admins = grants | |
| 3823 | + | .iter() | |
| 3824 | + | .find(|g| g["user_id"].as_str() == Some(&admin_id.to_string())) | |
| 3825 | + | .expect("admin's grant"); | |
| 3826 | + | let admin_copy = open_gck_grant( | |
| 3827 | + | admins["sealed_gck"].as_str().expect("sealed_gck"), | |
| 3828 | + | &admin_identity, | |
| 3829 | + | &group_id.to_string(), | |
| 3830 | + | 8, | |
| 3831 | + | ) | |
| 3832 | + | .expect("admin opens their own grant"); | |
| 3833 | + | assert_eq!( | |
| 3834 | + | admin_copy, new_gck, | |
| 3835 | + | "every member must be sealed the same new key" | |
| 3836 | + | ); | |
| 3837 | + | } | |
| 3838 | + | ||
| 3839 | + | #[tokio::test] | |
| 3840 | + | async fn a_grant_cannot_be_opened_by_the_member_it_was_not_sealed_to() { | |
| 3841 | + | let server = MockServer::start().await; | |
| 3842 | + | let (client, admin_identity) = admin_client(&server); | |
| 3843 | + | let (admin_id, _) = test_ids(); | |
| 3844 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 3845 | + | ||
| 3846 | + | let bob = Member::new(); | |
| 3847 | + | let carol = Member::new(); | |
| 3848 | + | mount_reads( | |
| 3849 | + | &server, | |
| 3850 | + | group_id, | |
| 3851 | + | &generate_group_key(), | |
| 3852 | + | &admin_identity, | |
| 3853 | + | admin_id, | |
| 3854 | + | 1, | |
| 3855 | + | &[&bob, &carol], | |
| 3856 | + | ) | |
| 3857 | + | .await; | |
| 3858 | + | mount_rotate(&server).await; | |
| 3859 | + | ||
| 3860 | + | client | |
| 3861 | + | .rotate_group_key(group_id, &[]) | |
| 3862 | + | .await | |
| 3863 | + | .expect("rotate without removing anyone"); | |
| 3864 | + | ||
| 3865 | + | let batch = posted_batch(&server).await; | |
| 3866 | + | let bobs = batch["grants"] | |
| 3867 | + | .as_array() | |
| 3868 | + | .expect("grants") | |
| 3869 | + | .iter() | |
| 3870 | + | .find(|g| g["user_id"].as_str() == Some(&bob.user_id.to_string())) | |
| 3871 | + | .expect("bob's grant")["sealed_gck"] | |
| 3872 | + | .as_str() | |
| 3873 | + | .expect("sealed_gck") | |
| 3874 | + | .to_string(); | |
| 3875 | + | ||
| 3876 | + | assert!( | |
| 3877 | + | open_gck_grant(&bobs, &carol.keypair, &group_id.to_string(), 2).is_err(), | |
| 3878 | + | "a grant sealed to bob must not open under carol's key" | |
| 3879 | + | ); | |
| 3880 | + | } | |
| 3881 | + | ||
| 3882 | + | #[tokio::test] | |
| 3883 | + | async fn an_empty_removal_set_rekeys_without_dropping_anyone() { | |
| 3884 | + | let server = MockServer::start().await; | |
| 3885 | + | let (client, admin_identity) = admin_client(&server); | |
| 3886 | + | let (admin_id, _) = test_ids(); | |
| 3887 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 3888 | + | ||
| 3889 | + | let bob = Member::new(); | |
| 3890 | + | mount_reads( | |
| 3891 | + | &server, | |
| 3892 | + | group_id, | |
| 3893 | + | &generate_group_key(), | |
| 3894 | + | &admin_identity, | |
| 3895 | + | admin_id, | |
| 3896 | + | 3, | |
| 3897 | + | &[&bob], | |
| 3898 | + | ) | |
| 3899 | + | .await; | |
| 3900 | + | mount_rotate(&server).await; | |
| 3901 | + | ||
| 3902 | + | client | |
| 3903 | + | .rotate_group_key(group_id, &[]) | |
| 3904 | + | .await | |
| 3905 | + | .expect("rekey after a suspected compromise"); | |
| 3906 | + | ||
| 3907 | + | let batch = posted_batch(&server).await; | |
| 3908 | + | assert_eq!(batch["gck_version"], 4); | |
| 3909 | + | assert_eq!( | |
| 3910 | + | batch["grants"].as_array().expect("grants").len(), | |
| 3911 | + | 2, | |
| 3912 | + | "a bare re-key keeps the whole membership" | |
| 3913 | + | ); | |
| 3914 | + | } | |
| 3915 | + | ||
| 3916 | + | #[tokio::test] | |
| 3917 | + | async fn a_member_pubkey_the_client_cannot_parse_aborts_the_rotation() { | |
| 3918 | + | let server = MockServer::start().await; | |
| 3919 | + | let (client, admin_identity) = admin_client(&server); | |
| 3920 | + | let (admin_id, _) = test_ids(); | |
| 3921 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 3922 | + | ||
| 3923 | + | let sealed = seal_gck_to_member( | |
| 3924 | + | &generate_group_key(), | |
| 3925 | + | &admin_identity.public_key(), | |
| 3926 | + | &group_id.to_string(), | |
| 3927 | + | 1, | |
| 3928 | + | ) | |
| 3929 | + | .expect("seal admin grant"); | |
| 3930 | + | Mock::given(method("GET")) | |
| 3931 | + | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 3932 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3933 | + | "sealed_gck": sealed, | |
| 3934 | + | "gck_version": 1, | |
| 3935 | + | }))) | |
| 3936 | + | .mount(&server) | |
| 3937 | + | .await; | |
| 3938 | + | Mock::given(method("GET")) | |
| 3939 | + | .and(path(format!("/api/v1/sync/groups/{group_id}/pubkeys"))) | |
| 3940 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!([ | |
| 3941 | + | { "user_id": admin_id, "pubkey": admin_identity.public_key().to_base64() }, | |
| 3942 | + | { "user_id": Uuid::new_v4(), "pubkey": "not-a-key" }, | |
| 3943 | + | ]))) | |
| 3944 | + | .mount(&server) | |
| 3945 | + | .await; | |
| 3946 | + | mount_rotate(&server).await; | |
| 3947 | + | ||
| 3948 | + | client | |
| 3949 | + | .rotate_group_key(group_id, &[]) | |
| 3950 | + | .await | |
| 3951 | + | .expect_err("an unreadable member key must not produce a partial rotation"); | |
| 3952 | + | ||
| 3953 | + | let reqs = server.received_requests().await.expect("requests"); | |
| 3954 | + | assert_eq!( | |
| 3955 | + | reqs.iter() | |
| 3956 | + | .filter(|r| r.url.path().ends_with("/rotate")) | |
| 3957 | + | .count(), | |
| 3958 | + | 0, | |
| 3959 | + | "nothing may be posted when the batch could not be built in full" | |
| 3960 | + | ); | |
| 3961 | + | } | |
| 3962 | + | ||
| 3963 | + | /// `IdentityPublicKey` round-trips through the wire form the pubkey list uses. | |
| 3964 | + | /// If this ever stops holding, every rotation silently degrades to the error | |
| 3965 | + | /// path above. | |
| 3966 | + | #[test] | |
| 3967 | + | fn member_pubkeys_round_trip_through_base64() { | |
| 3968 | + | let identity = IdentityKeypair::generate(); | |
| 3969 | + | let encoded = identity.public_key().to_base64(); | |
| 3970 | + | let decoded = IdentityPublicKey::from_base64(&encoded).expect("round-trip"); | |
| 3971 | + | assert_eq!(decoded.as_bytes(), identity.public_key().as_bytes()); | |
| 3972 | + | } | |
| 3973 | + | } | |
| 3974 | + | ||
| 3975 | + | /// The client half of history-survives-rotation: one pull can span GCK | |
| 3976 | + | /// generations, and each entry is opened under the key it was sealed with. | |
| 3977 | + | mod group_generations { | |
| 3978 | + | use super::*; | |
| 3979 | + | use synckit_client::{ | |
| 3980 | + | ChangeEntry, GroupId, IdentityKeypair, generate_group_key, seal_gck_to_member, | |
| 3981 | + | }; | |
| 3982 | + | use wiremock::matchers::{path_regex, query_param}; | |
| 3983 | + | ||
| 3984 | + | fn change(row: &str, title: &str) -> ChangeEntry { | |
| 3985 | + | ChangeEntry { | |
| 3986 | + | table: "tasks".to_string(), | |
| 3987 | + | op: ChangeOp::Insert, | |
| 3988 | + | row_id: row.to_string(), | |
| 3989 | + | timestamp: Utc::now(), | |
| 3990 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 3991 | + | data: Some(json!({ "title": title })), | |
| 3992 | + | extra: serde_json::Map::default(), | |
| 3993 | + | } | |
| 3994 | + | } | |
| 3995 | + | ||
| 3996 | + | /// Push one change under `gck` and return the ciphertext the client produced, | |
| 3997 | + | /// so it can be served straight back in a pull. Going through the real push | |
| 3998 | + | /// path keeps the fixture honest: no test-local reimplementation of the AAD | |
| 3999 | + | /// binding to drift from the one the client uses. | |
| 4000 | + | async fn sealed_entry( | |
| 4001 | + | client: &SyncKitClient, | |
| 4002 | + | server: &MockServer, | |
| 4003 | + | group_id: GroupId, | |
| 4004 | + | gck: &[u8; 32], | |
| 4005 | + | device: DeviceId, | |
| 4006 | + | row: &str, | |
| 4007 | + | title: &str, | |
| 4008 | + | ) -> serde_json::Value { | |
| 4009 | + | let before = server.received_requests().await.expect("requests").len(); | |
| 4010 | + | client | |
| 4011 | + | .group_push(group_id, gck, device, vec![change(row, title)]) | |
| 4012 | + | .await | |
| 4013 | + | .expect("group push"); | |
| 4014 | + | let reqs = server.received_requests().await.expect("requests"); | |
| 4015 | + | let pushed = reqs[before..] | |
| 4016 | + | .iter() | |
| 4017 | + | .find(|r| r.url.path().ends_with("/push")) | |
| 4018 | + | .expect("a push was sent"); | |
| 4019 | + | let body: serde_json::Value = serde_json::from_slice(&pushed.body).expect("push body"); | |
| 4020 | + | body["changes"][0].clone() | |
| 4021 | + | } | |
| 4022 | + | ||
| 4023 | + | #[tokio::test] | |
| 4024 | + | async fn a_pull_spanning_two_generations_opens_each_under_its_own_key() { | |
| 4025 | + | let server = MockServer::start().await; | |
| 4026 | + | let client = authed_client(&server); | |
| 4027 | + | let master = synckit_client::crypto::generate_master_key(); | |
| 4028 | + | let identity = IdentityKeypair::from_master_key(&master); | |
| 4029 | + | client.set_master_key_raw(master); | |
| 4030 | + | ||
| 4031 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 4032 | + | let device = DeviceId::new(Uuid::new_v4()); | |
| 4033 | + | let group_ref = group_id.to_string(); | |
| 4034 | + | let gck_v1 = generate_group_key(); | |
| 4035 | + | let gck_v2 = generate_group_key(); | |
| 4036 | + | ||
| 4037 | + | Mock::given(method("POST")) | |
| 4038 | + | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 4039 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "cursor": 1 }))) | |
| 4040 | + | .mount(&server) | |
| 4041 | + | .await; | |
| 4042 | + | ||
| 4043 | + | let old = sealed_entry( | |
| 4044 | + | &client, | |
| 4045 | + | &server, | |
| 4046 | + | group_id, | |
| 4047 | + | &gck_v1, | |
| 4048 | + | device, | |
| 4049 | + | "r-old", | |
| 4050 | + | "before rotation", | |
| 4051 | + | ) | |
| 4052 | + | .await; | |
| 4053 | + | let new = sealed_entry( | |
| 4054 | + | &client, | |
| 4055 | + | &server, | |
| 4056 | + | group_id, | |
| 4057 | + | &gck_v2, | |
| 4058 | + | device, | |
| 4059 | + | "r-new", | |
| 4060 | + | "after rotation", | |
| 4061 | + | ) | |
| 4062 | + | .await; | |
| 4063 | + | ||
| 4064 | + | // Each generation's grant is fetched by version. Serving only these two | |
| 4065 | + | // means a client that ignored the per-entry version and asked for one key | |
| 4066 | + | // would still get an answer, and then fail to decrypt half the batch. | |
| 4067 | + | for (version, gck) in [(1, &gck_v1), (2, &gck_v2)] { | |
| 4068 | + | let sealed = | |
| 4069 | + | seal_gck_to_member(gck, &identity.public_key(), &group_ref, version).expect("seal"); | |
| 4070 | + | Mock::given(method("GET")) | |
| 4071 | + | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 4072 | + | .and(query_param("version", version.to_string())) | |
| 4073 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 4074 | + | "sealed_gck": sealed, | |
| 4075 | + | "gck_version": version, | |
| 4076 | + | }))) | |
| 4077 | + | .mount(&server) | |
| 4078 | + | .await; | |
| 4079 | + | } | |
| 4080 | + | ||
| 4081 | + | Mock::given(method("POST")) | |
| 4082 | + | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/pull$")) | |
| 4083 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 4084 | + | "changes": [ | |
| 4085 | + | { | |
| 4086 | + | "seq": 1, | |
| 4087 | + | "device_id": device, | |
| 4088 | + | "table": old["table"], | |
| 4089 | + | "op": old["op"], | |
| 4090 | + | "row_id": old["row_id"], | |
| 4091 | + | "timestamp": old["timestamp"], | |
| 4092 | + | "data": old["data"], | |
| 4093 | + | "gck_version": 1, | |
| 4094 | + | }, | |
| 4095 | + | { | |
| 4096 | + | "seq": 2, | |
| 4097 | + | "device_id": device, | |
| 4098 | + | "table": new["table"], | |
| 4099 | + | "op": new["op"], | |
| 4100 | + | "row_id": new["row_id"], | |
| 4101 | + | "timestamp": new["timestamp"], | |
| 4102 | + | "data": new["data"], | |
| 4103 | + | "gck_version": 2, | |
| 4104 | + | }, | |
| 4105 | + | ], | |
| 4106 | + | "cursor": 2, | |
| 4107 | + | "has_more": false, | |
| 4108 | + | }))) | |
| 4109 | + | .mount(&server) | |
| 4110 | + | .await; | |
| 4111 | + | ||
| 4112 | + | let (changes, cursor, has_more) = client | |
| 4113 | + | .group_pull_rich(group_id, 2, device, 0) | |
| 4114 | + | .await | |
| 4115 | + | .expect("a pull spanning generations must succeed"); | |
| 4116 | + | ||
| 4117 | + | assert_eq!(cursor, 2); | |
| 4118 | + | assert!(!has_more); | |
| 4119 | + | assert_eq!(changes.len(), 2); | |
| 4120 | + | assert_eq!( | |
| 4121 | + | changes[0].entry.data.as_ref().expect("old plaintext"), | |
| 4122 | + | &json!({ "title": "before rotation" }), | |
| 4123 | + | "the pre-rotation entry must open under generation 1" | |
| 4124 | + | ); | |
| 4125 | + | assert_eq!( | |
| 4126 | + | changes[1].entry.data.as_ref().expect("new plaintext"), | |
| 4127 | + | &json!({ "title": "after rotation" }), | |
| 4128 | + | "the post-rotation entry must open under generation 2" | |
| 4129 | + | ); | |
| 4130 | + | } | |
| 4131 | + | ||
| 4132 | + | /// An entry with no generation is what a server predating per-generation | |
| 4133 | + | /// grants returns. The caller's current generation is the fallback, so an old | |
| 4134 | + | /// server keeps working rather than failing every pull. | |
| 4135 | + | #[tokio::test] | |
| 4136 | + | async fn an_entry_without_a_generation_falls_back_to_the_current_one() { | |
| 4137 | + | let server = MockServer::start().await; | |
| 4138 | + | let client = authed_client(&server); | |
| 4139 | + | let master = synckit_client::crypto::generate_master_key(); | |
| 4140 | + | let identity = IdentityKeypair::from_master_key(&master); | |
| 4141 | + | client.set_master_key_raw(master); | |
| 4142 | + | ||
| 4143 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 4144 | + | let device = DeviceId::new(Uuid::new_v4()); | |
| 4145 | + | let gck = generate_group_key(); | |
| 4146 | + | ||
| 4147 | + | Mock::given(method("POST")) | |
| 4148 | + | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 4149 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "cursor": 1 }))) | |
| 4150 | + | .mount(&server) | |
| 4151 | + | .await; | |
| 4152 | + | let entry = sealed_entry( | |
| 4153 | + | &client, | |
| 4154 | + | &server, | |
| 4155 | + | group_id, | |
| 4156 | + | &gck, | |
| 4157 | + | device, |
Lines truncated
| @@ -1,11 +1,12 @@ | |||
| 1 | 1 | //! Group sync: list the caller's groups, fetch their sealed GCK grant, and | |
| 2 | 2 | //! push/pull a group's shared changelog under its Group Content Key. | |
| 3 | 3 | //! | |
| 4 | - | //! The GCK is supplied by the caller, the `SyncStore` resolves it from the grant | |
| 5 | - | //! via the identity key (a later slice); these methods only encrypt/decrypt with | |
| 6 | - | //! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so | |
| 7 | - | //! a ciphertext cannot be relocated across groups. Design: wiki | |
| 8 | - | //! synckit-multiscope-design. | |
| 4 | + | //! Push takes the GCK from the caller; pull resolves one per entry, because a | |
| 5 | + | //! rotation bumps the group's generation without re-encrypting what came before | |
| 6 | + | //! it and a single pull can therefore span generations. Group entries bind | |
| 7 | + | //! `(group_id, table, row_id)` as AEAD associated data, so a ciphertext cannot be | |
| 8 | + | //! relocated across groups. Design: wiki synckit-multiscope-design, | |
| 9 | + | //! synckit-groups-design. | |
| 9 | 10 | ||
| 10 | 11 | use bytes::Bytes; | |
| 11 | 12 | use tracing::instrument; | |
| @@ -17,8 +18,8 @@ | |||
| 17 | 18 | identity::{IdentityKeypair, IdentityPublicKey, generate_group_key, seal_gck_to_member}, | |
| 18 | 19 | ids::{DeviceId, GroupId, UserId}, | |
| 19 | 20 | types::{ | |
| 20 | - | ChangeEntry, GroupGrant, GroupMember, PullRequest, PullResponse, PulledChange, | |
| 21 | - | PushResponse, SyncGroup, WirePushRequest, | |
| 21 | + | ChangeEntry, GroupGrant, GroupMember, GroupMemberPubkey, PullRequest, PullResponse, | |
| 22 | + | PulledChange, PushResponse, SyncGroup, WirePushRequest, | |
| 22 | 23 | }, | |
| 23 | 24 | }; | |
| 24 | 25 | ||
| @@ -35,6 +36,21 @@ | |||
| 35 | 36 | admin_pubkey: String, | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 39 | + | /// One member's re-sealed grant in a rotation batch. | |
| 40 | + | #[derive(serde::Serialize)] | |
| 41 | + | struct RotateGrant { | |
| 42 | + | user_id: UserId, | |
| 43 | + | sealed_gck: String, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Request body for `POST /groups/{id}/rotate`. The grant set is the new | |
| 47 | + | /// membership: the server drops anyone absent from it. | |
| 48 | + | #[derive(serde::Serialize)] | |
| 49 | + | struct RotateBody { | |
| 50 | + | gck_version: i32, | |
| 51 | + | grants: Vec<RotateGrant>, | |
| 52 | + | } | |
| 53 | + | ||
| 38 | 54 | /// Request body for `POST /groups/{id}/members`. | |
| 39 | 55 | #[derive(serde::Serialize)] | |
| 40 | 56 | struct AddMemberBody<'a> { | |
| @@ -163,15 +179,117 @@ | |||
| 163 | 179 | .await | |
| 164 | 180 | } | |
| 165 | 181 | ||
| 166 | - | /// Remove a member from a group by user id. Admin only. The server membership | |
| 167 | - | /// ACL revokes the member's group read/write access immediately. | |
| 182 | + | /// List every member's identity public key. Admin only. The input to a | |
| 183 | + | /// re-seal: [`rotate_group_key`](Self::rotate_group_key) consumes this. | |
| 184 | + | #[instrument(skip(self))] | |
| 185 | + | pub async fn list_member_pubkeys(&self, group_id: GroupId) -> Result<Vec<GroupMemberPubkey>> { | |
| 186 | + | let token = self.require_token()?; | |
| 187 | + | let url = self.endpoints.group_pubkeys(group_id); | |
| 188 | + | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 189 | + | let req = self.http.get(&url).bearer_auth(&token); | |
| 190 | + | async move { check_response(req.send().await?).await } | |
| 191 | + | }) | |
| 192 | + | .await | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Rotate the group's Group Content Key, keeping everyone except `remove`. | |
| 196 | + | /// Admin only. | |
| 168 | 197 | /// | |
| 169 | - | /// Forward secrecy for writes made *after* removal requires rotating the GCK | |
| 170 | - | /// (re-mint, re-seal to the remaining members, bump the generation), which the | |
| 171 | - | /// server does not yet expose, a later slice. This method performs the | |
| 172 | - | /// membership revocation only; data the member already pulled is in their hands. | |
| 198 | + | /// Mints a fresh GCK, seals it to every remaining member's stored public key, | |
| 199 | + | /// and posts the batch. The server treats the grant set as the new membership, | |
| 200 | + | /// so the removal and the re-key commit together: no window exists in which a | |
| 201 | + | /// removed member's key is still the current one. | |
| 202 | + | /// | |
| 203 | + | /// Pass an empty `remove` to re-key without dropping anyone (a suspected key | |
| 204 | + | /// compromise). Removing the admin is refused server-side; that would orphan | |
| 205 | + | /// the group. | |
| 206 | + | /// | |
| 207 | + | /// This does NOT re-encrypt history. Entries written before the rotation stay | |
| 208 | + | /// under the old generation, and anything a removed member already pulled is | |
| 209 | + | /// in their hands regardless. | |
| 210 | + | #[instrument(skip(self))] | |
| 211 | + | pub async fn rotate_group_key(&self, group_id: GroupId, remove: &[UserId]) -> Result<()> { | |
| 212 | + | let token = self.require_token()?; | |
| 213 | + | ||
| 214 | + | // The generation to write is one past what our own grant reports. Reading | |
| 215 | + | // it from the grant rather than from `list_groups` keeps the rotation | |
| 216 | + | // anchored to the key we can actually open. | |
| 217 | + | let current = self.group_grant(group_id).await?.gck_version; | |
| 218 | + | let next = current | |
| 219 | + | .checked_add(1) | |
| 220 | + | .ok_or_else(|| crate::error::SyncKitError::Crypto("gck_version overflow".into()))?; | |
| 221 | + | ||
| 222 | + | let pubkeys = self.list_member_pubkeys(group_id).await?; | |
| 223 | + | let gck = generate_group_key(); | |
| 224 | + | let group_ref = group_id.to_string(); | |
| 225 | + | ||
| 226 | + | let mut grants = Vec::with_capacity(pubkeys.len()); | |
| 227 | + | for entry in &pubkeys { | |
| 228 | + | if remove.contains(&entry.user_id) { | |
| 229 | + | continue; | |
| 230 | + | } | |
| 231 | + | let pubkey = IdentityPublicKey::from_base64(&entry.pubkey)?; | |
| 232 | + | grants.push(RotateGrant { | |
| 233 | + | user_id: entry.user_id, | |
| 234 | + | sealed_gck: seal_gck_to_member(&gck, &pubkey, &group_ref, next)?, | |
| 235 | + | }); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | let body = Bytes::from(serde_json::to_vec(&RotateBody { | |
| 239 | + | gck_version: next, | |
| 240 | + | grants, | |
| 241 | + | })?); | |
| 242 | + | let url = self.endpoints.group_rotate(group_id); | |
| 243 | + | ||
| 244 | + | // The generation is the optimistic version: a replay of a rotation that | |
| 245 | + | // already committed carries a `gck_version` the group has moved past and | |
| 246 | + | // is refused, so a duplicate delivery cannot mint a second rotation. | |
| 247 | + | self.retry_request( | |
| 248 | + | Idempotency::IdempotentWrite { | |
| 249 | + | on: "gck_version must strictly advance; a replayed rotation is refused", | |
| 250 | + | }, | |
| 251 | + | || { | |
| 252 | + | let req = self | |
| 253 | + | .http | |
| 254 | + | .post(&url) | |
| 255 | + | .bearer_auth(&token) | |
| 256 | + | .header("content-type", "application/json") | |
| 257 | + | .body(body.clone()); | |
| 258 | + | async move { check_response(req.send().await?).await } | |
| 259 | + | }, | |
| 260 | + | ) | |
| 261 | + | .await?; | |
| 262 | + | ||
| 263 | + | // Our own cached GCK is now a generation behind. | |
| 264 | + | self.invalidate_gck(group_id); | |
| 265 | + | Ok(()) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Remove a member from a group by user id, rotating the group key in the | |
| 269 | + | /// same operation. Admin only. | |
| 270 | + | /// | |
| 271 | + | /// Revocation alone would leave the removed member holding a working GCK, so | |
| 272 | + | /// this drives [`rotate_group_key`](Self::rotate_group_key): the server drops | |
| 273 | + | /// the member and re-keys the group in one transaction. Data the member | |
| 274 | + | /// already pulled is in their hands and no rotation changes that. | |
| 173 | 275 | #[instrument(skip(self))] | |
| 174 | 276 | pub async fn remove_member(&self, group_id: GroupId, member: UserId) -> Result<()> { | |
| 277 | + | self.rotate_group_key(group_id, &[member]).await | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Remove a member without rotating the group key. Admin only. | |
| 281 | + | /// | |
| 282 | + | /// Revocation only: the member loses server-side access immediately, but a | |
| 283 | + | /// copy of the GCK they kept still opens group ciphertext, including entries | |
| 284 | + | /// written after this call. Prefer [`remove_member`](Self::remove_member). | |
| 285 | + | /// This exists for the case where the re-seal cannot be performed (a member's | |
| 286 | + | /// stored public key is unreadable, say) and revoking access now beats waiting. | |
| 287 | + | #[instrument(skip(self))] | |
| 288 | + | pub async fn revoke_member_without_rekey( | |
| 289 | + | &self, | |
| 290 | + | group_id: GroupId, | |
| 291 | + | member: UserId, | |
| 292 | + | ) -> Result<()> { | |
| 175 | 293 | let token = self.require_token()?; | |
| 176 | 294 | let url = self.endpoints.group_member(group_id, member); | |
| 177 | 295 | self.retry_request(Idempotency::Keyed, || { | |
| @@ -182,8 +300,10 @@ | |||
| 182 | 300 | Ok(()) | |
| 183 | 301 | } | |
| 184 | 302 | ||
| 185 | - | /// Fetch the caller's own sealed GCK grant for a group. Open it with the | |
| 186 | - | /// member's identity private key to recover the GCK. | |
| 303 | + | /// Fetch the caller's newest sealed GCK grant for a group. Open it with the | |
| 304 | + | /// member's identity private key to recover the GCK. This is the key to write | |
| 305 | + | /// under; reading an entry from before a rotation wants | |
| 306 | + | /// [`group_grant_at`](Self::group_grant_at). | |
| 187 | 307 | #[instrument(skip(self))] | |
| 188 | 308 | pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> { | |
| 189 | 309 | let token = self.require_token()?; | |
| @@ -195,6 +315,25 @@ | |||
| 195 | 315 | .await | |
| 196 | 316 | } | |
| 197 | 317 | ||
| 318 | + | /// Fetch the caller's sealed grant for one specific GCK generation, so a | |
| 319 | + | /// device can decrypt entries written before a rotation it lived through. | |
| 320 | + | /// | |
| 321 | + | /// A generation the caller never held (it predates their joining, or they were | |
| 322 | + | /// removed and re-added) is a 403. | |
| 323 | + | #[instrument(skip(self))] | |
| 324 | + | pub async fn group_grant_at(&self, group_id: GroupId, gck_version: i32) -> Result<GroupGrant> { | |
| 325 | + | let token = self.require_token()?; | |
| 326 | + | let url = format!( | |
| 327 | + | "{}?version={gck_version}", | |
| 328 | + | self.endpoints.group_grant(group_id) | |
| 329 | + | ); | |
| 330 | + | self.retry_request_json(Idempotency::ReadOnly, || { | |
| 331 | + | let req = self.http.get(&url).bearer_auth(&token); | |
| 332 | + | async move { check_response(req.send().await?).await } | |
| 333 | + | }) | |
| 334 | + | .await | |
| 335 | + | } | |
| 336 | + | ||
| 198 | 337 | /// Push encrypted changes to a group's shared changelog under its GCK. | |
| 199 | 338 | /// Returns the server cursor after the push. | |
| 200 | 339 | #[instrument(skip(self, gck, changes))] | |
| @@ -233,16 +372,21 @@ | |||
| 233 | 372 | Ok(push_resp.cursor) | |
| 234 | 373 | } | |
| 235 | 374 | ||
| 236 | - | /// Pull a group's changes since `cursor`, decrypting under its GCK. Returns | |
| 237 | - | /// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready | |
| 238 | - | /// for conflict resolution. Group pull has no master-key-rotation window (a | |
| 239 | - | /// group's key rotates server-side, re-encrypted in place), so the decrypt is | |
| 240 | - | /// a single-key pass. | |
| 241 | - | #[instrument(skip(self, gck))] | |
| 375 | + | /// Pull a group's changes since `cursor`, decrypting each entry under the GCK | |
| 376 | + | /// generation it was sealed with. Returns `(changes, new_cursor, has_more)` | |
| 377 | + | /// with per-row device/seq metadata, ready for conflict resolution. | |
| 378 | + | /// | |
| 379 | + | /// A rotation bumps the group's generation without re-encrypting history, so a | |
| 380 | + | /// single pull can span generations. Each entry carries its own, resolved | |
| 381 | + | /// through the cache in [`group_content_key`](Self::group_content_key), so the | |
| 382 | + | /// common case (one generation) is still one key and no extra request. | |
| 383 | + | /// `current_gck_version` is the fallback for an entry with no generation, which | |
| 384 | + | /// is what a server predating per-generation grants returns. | |
| 385 | + | #[instrument(skip(self))] | |
| 242 | 386 | pub async fn group_pull_rich( | |
| 243 | 387 | &self, | |
| 244 | 388 | group_id: GroupId, | |
| 245 | - | gck: &[u8; 32], | |
| 389 | + | current_gck_version: i32, | |
| 246 | 390 | device_id: DeviceId, | |
| 247 | 391 | cursor: i64, | |
| 248 | 392 | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| @@ -263,23 +407,28 @@ | |||
| 263 | 407 | .await?; | |
| 264 | 408 | ||
| 265 | 409 | let group_str = group_id.to_string(); | |
| 266 | - | let changes = pull_resp | |
| 267 | - | .changes | |
| 268 | - | .into_iter() | |
| 269 | - | .map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck)) | |
| 270 | - | .collect::<Result<Vec<_>>>()?; | |
| 410 | + | let mut changes = Vec::with_capacity(pull_resp.changes.len()); | |
| 411 | + | for entry in pull_resp.changes { | |
| 412 | + | let version = entry.gck_version.unwrap_or(current_gck_version); | |
| 413 | + | let gck = self.group_content_key(group_id, version).await?; | |
| 414 | + | changes.push(Self::decrypt_group_change_to_pulled( | |
| 415 | + | &group_str, entry, &gck, | |
| 416 | + | )?); | |
| 417 | + | } | |
| 271 | 418 | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 272 | 419 | } | |
| 273 | 420 | ||
| 274 | 421 | /// Resolve a group's decrypted Group Content Key for `gck_version`, fetching | |
| 275 | - | /// and opening the sealed grant on a cache miss. | |
| 422 | + | /// and opening that generation's sealed grant on a cache miss. | |
| 276 | 423 | /// | |
| 277 | - | /// Fast path: a cached GCK at the requested version is returned without a | |
| 278 | - | /// network call. On a miss (or a version mismatch, the group's key rotated), | |
| 279 | - | /// the sealed grant is fetched, opened with the identity keypair derived from | |
| 280 | - | /// this client's master key, and cached under the grant's actual version. The | |
| 281 | - | /// master key and identity secret never leave the client, the `SyncStore` | |
| 282 | - | /// only ever receives the resolved GCK. | |
| 424 | + | /// Fast path: a cached GCK for exactly that generation is returned without a | |
| 425 | + | /// network call. On a miss the grant for that generation is fetched, opened | |
| 426 | + | /// with the identity keypair derived from this client's master key, and | |
| 427 | + | /// cached. The master key and identity secret never leave the client, the | |
| 428 | + | /// `SyncStore` only ever receives the resolved GCK. | |
| 429 | + | /// | |
| 430 | + | /// Asking for a generation the caller never held is a server-side 403: a | |
| 431 | + | /// member sees the group from when they joined, not before. | |
| 283 | 432 | // Consumed by `sync_now`'s scope iteration in slice 4. | |
| 284 | 433 | #[allow(dead_code)] | |
| 285 | 434 | pub(crate) async fn group_content_key( | |
| @@ -287,19 +436,17 @@ | |||
| 287 | 436 | group_id: GroupId, | |
| 288 | 437 | gck_version: i32, | |
| 289 | 438 | ) -> Result<crypto::ZeroizeOnDrop> { | |
| 290 | - | if let Some((v, gck)) = self.gck_cache.read().get(&group_id) | |
| 291 | - | && *v == gck_version | |
| 292 | - | { | |
| 439 | + | if let Some(gck) = self.gck_cache.read().get(&(group_id, gck_version)) { | |
| 293 | 440 | return Ok(crypto::ZeroizeOnDrop(**gck)); | |
| 294 | 441 | } | |
| 295 | 442 | ||
| 296 | - | let grant = self.group_grant(group_id).await?; | |
| 443 | + | let grant = self.group_grant_at(group_id, gck_version).await?; | |
| 297 | 444 | let master = self.require_master_key()?; | |
| 298 | 445 | let gck = Self::open_group_grant(&grant, &master, group_id)?; | |
| 299 | 446 | let out = crypto::ZeroizeOnDrop(*gck); | |
| 300 | 447 | self.gck_cache | |
| 301 | 448 | .write() | |
| 302 | - | .insert(group_id, (grant.gck_version, gck)); | |
| 449 | + | .insert((group_id, grant.gck_version), gck); | |
| 303 | 450 | Ok(out) | |
| 304 | 451 | } | |
| 305 | 452 | ||
| @@ -320,13 +467,13 @@ | |||
| 320 | 467 | Ok(crypto::ZeroizeOnDrop(gck)) | |
| 321 | 468 | } | |
| 322 | 469 | ||
| 323 | - | /// Drop a group's cached GCK, forcing the next | |
| 324 | - | /// [`group_content_key`](Self::group_content_key) to re-fetch its grant. Call | |
| 470 | + | /// Drop every cached generation for a group, forcing the next | |
| 471 | + | /// [`group_content_key`](Self::group_content_key) to re-fetch its grants. Call | |
| 325 | 472 | /// on a decrypt failure (a stale key after a rotation this client missed). | |
| 326 | 473 | // Consumed by `sync_now`'s scope iteration in slice 4. | |
| 327 | 474 | #[allow(dead_code)] | |
| 328 | 475 | pub(crate) fn invalidate_gck(&self, group_id: GroupId) { | |
| 329 | - | self.gck_cache.write().remove(&group_id); | |
| 476 | + | self.gck_cache.write().retain(|(g, _), _| *g != group_id); | |
| 330 | 477 | } | |
| 331 | 478 | } | |
| 332 | 479 | ||
| @@ -360,6 +507,7 @@ | |||
| 360 | 507 | timestamp: wire.timestamp, | |
| 361 | 508 | data: wire.data, | |
| 362 | 509 | key_id: None, | |
| 510 | + | gck_version: None, | |
| 363 | 511 | } | |
| 364 | 512 | } | |
| 365 | 513 |
| @@ -789,6 +789,7 @@ | |||
| 789 | 789 | timestamp: wire.timestamp, | |
| 790 | 790 | data: wire.data, | |
| 791 | 791 | key_id: None, | |
| 792 | + | gck_version: None, | |
| 792 | 793 | }; | |
| 793 | 794 | let decrypted = client.decrypt_change(pull_entry).unwrap(); | |
| 794 | 795 | assert_eq!(decrypted.op, ChangeOp::Delete); | |
| @@ -873,6 +874,7 @@ | |||
| 873 | 874 | timestamp: wire.timestamp, | |
| 874 | 875 | data: wire.data, | |
| 875 | 876 | key_id: None, | |
| 877 | + | gck_version: None, | |
| 876 | 878 | }; | |
| 877 | 879 | ||
| 878 | 880 | let decrypted = client.decrypt_change(pull_entry).unwrap(); | |
| @@ -894,6 +896,7 @@ | |||
| 894 | 896 | timestamp: Utc::now(), | |
| 895 | 897 | data: None, | |
| 896 | 898 | key_id: None, | |
| 899 | + | gck_version: None, | |
| 897 | 900 | }; | |
| 898 | 901 | ||
| 899 | 902 | let decrypted = client.decrypt_change(pull_entry).unwrap(); | |
| @@ -914,6 +917,7 @@ | |||
| 914 | 917 | timestamp: Utc::now(), | |
| 915 | 918 | data: Some(serde_json::json!("some-encrypted-string")), | |
| 916 | 919 | key_id: None, | |
| 920 | + | gck_version: None, | |
| 917 | 921 | }; | |
| 918 | 922 | ||
| 919 | 923 | let err = client.decrypt_change(pull_entry).unwrap_err(); | |
| @@ -1245,6 +1249,7 @@ | |||
| 1245 | 1249 | timestamp: wire.timestamp, | |
| 1246 | 1250 | data: wire.data, | |
| 1247 | 1251 | key_id: None, | |
| 1252 | + | gck_version: None, | |
| 1248 | 1253 | }; | |
| 1249 | 1254 | let decrypted = client.decrypt_change(pull).unwrap(); | |
| 1250 | 1255 | assert_eq!(decrypted.table, entries[i].table); | |
| @@ -1281,6 +1286,7 @@ | |||
| 1281 | 1286 | timestamp: wire.timestamp, | |
| 1282 | 1287 | data: wire.data, | |
| 1283 | 1288 | key_id: None, | |
| 1289 | + | gck_version: None, | |
| 1284 | 1290 | }; | |
| 1285 | 1291 | let decrypted = client.decrypt_change(pull).unwrap(); | |
| 1286 | 1292 | assert_eq!( | |
| @@ -1315,6 +1321,7 @@ | |||
| 1315 | 1321 | timestamp: wire.timestamp, | |
| 1316 | 1322 | data: wire.data, | |
| 1317 | 1323 | key_id: None, | |
| 1324 | + | gck_version: None, | |
| 1318 | 1325 | }; | |
| 1319 | 1326 | let decrypted = client.decrypt_change(pull).unwrap(); | |
| 1320 | 1327 | assert_eq!(decrypted.row_id, ""); | |
| @@ -1345,6 +1352,7 @@ | |||
| 1345 | 1352 | timestamp: Utc::now(), | |
| 1346 | 1353 | data: Some(encrypted), | |
| 1347 | 1354 | key_id, | |
| 1355 | + | gck_version: None, | |
| 1348 | 1356 | } | |
| 1349 | 1357 | } | |
| 1350 | 1358 |
| @@ -195,6 +195,17 @@ | |||
| 195 | 195 | format!("{}/{group_id}/members/{member}", self.groups_base) | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | + | /// `GET` every member's identity public key (admin only), the input to a | |
| 199 | + | /// re-seal. | |
| 200 | + | fn group_pubkeys(&self, group_id: GroupId) -> String { | |
| 201 | + | format!("{}/{group_id}/pubkeys", self.groups_base) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// `POST` a rotated GCK sealed to each remaining member (admin only). | |
| 205 | + | fn group_rotate(&self, group_id: GroupId) -> String { | |
| 206 | + | format!("{}/{group_id}/rotate", self.groups_base) | |
| 207 | + | } | |
| 208 | + | ||
| 198 | 209 | /// `POST` encrypted changes to a group's shared changelog. | |
| 199 | 210 | fn group_push(&self, group_id: GroupId) -> String { | |
| 200 | 211 | format!("{}/{group_id}/push", self.groups_base) | |
| @@ -324,11 +335,15 @@ | |||
| 324 | 335 | master_key_id: RwLock<i32>, | |
| 325 | 336 | /// Pending rotation key, if a rotation is in progress. | |
| 326 | 337 | pending_key: RwLock<Option<PendingKeyState>>, | |
| 327 | - | /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version, | |
| 328 | - | /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key) | |
| 329 | - | /// from the sealed grant; a version mismatch (rotation) or a decrypt failure | |
| 330 | - | /// forces a re-fetch. Holds one entry per group, the current generation. | |
| 331 | - | gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>, | |
| 338 | + | /// Decrypted Group Content Key cache, keyed by `(group_id, gck_version)`. | |
| 339 | + | /// Populated lazily by [`group_content_key`](Self::group_content_key) from | |
| 340 | + | /// that generation's sealed grant. | |
| 341 | + | /// | |
| 342 | + | /// Keyed by generation rather than by group because a rotation does not | |
| 343 | + | /// re-encrypt history: a pull can return entries from several generations at | |
| 344 | + | /// once, and each needs its own key. A decrypt failure drops every generation | |
| 345 | + | /// for the group, forcing a re-fetch. | |
| 346 | + | gck_cache: RwLock<std::collections::HashMap<(GroupId, i32), crypto::ZeroizeOnDrop>>, | |
| 332 | 347 | } | |
| 333 | 348 | ||
| 334 | 349 | impl SyncKitClient { |
| @@ -21,6 +21,7 @@ | |||
| 21 | 21 | ||
| 22 | 22 | use super::db::{get_sync_state, set_sync_state}; | |
| 23 | 23 | use super::schema::{ConflictStrategy, SyncSchema}; | |
| 24 | + | use super::stash; | |
| 24 | 25 | use crate::conflict::{Resolution, detect_conflicts, resolve_lww_at}; | |
| 25 | 26 | use crate::error::Result; | |
| 26 | 27 | use crate::ids::DeviceId; | |
| @@ -164,12 +165,23 @@ | |||
| 164 | 165 | /// clean vs conflicting against local pending, HLC-gate the clean set against the | |
| 165 | 166 | /// committed ledger, resolve conflicts by `resolve_lww`, and collapse to one | |
| 166 | 167 | /// entry per row (highest HLC wins, operation-agnostic). | |
| 168 | + | /// | |
| 169 | + | /// Every discard along that path is stashed under `scope` first (see | |
| 170 | + | /// [`super::stash`]). Three of them exist and they are easy to miscount: the | |
| 171 | + | /// conflict layer throws away the local side on `KeepRemote` and the remote side | |
| 172 | + | /// on `KeepLocal`/`Skip`, and the committed-HLC gate drops a superseded remote | |
| 173 | + | /// change without ever building a `ConflictPair` for it. A stash bolted onto the | |
| 174 | + | /// match arms alone would miss the third and quietest one. | |
| 175 | + | /// | |
| 176 | + | /// `ServerOrder` stashes nothing: it does not compare versions, so there is no | |
| 177 | + | /// loser to name. An app that picks it has chosen last-delivered-wins. | |
| 167 | 178 | pub fn resolve_pull( | |
| 168 | 179 | conn: &Connection, | |
| 169 | 180 | schema: &SyncSchema, | |
| 170 | 181 | node: DeviceId, | |
| 171 | 182 | pulled: Vec<PulledChange>, | |
| 172 | 183 | now: DateTime<Utc>, | |
| 184 | + | scope: &str, | |
| 173 | 185 | ) -> Result<Vec<ChangeEntry>> { | |
| 174 | 186 | match schema.conflict { | |
| 175 | 187 | ConflictStrategy::ServerOrder => Ok(pulled.into_iter().map(|p| p.entry).collect()), | |
| @@ -181,13 +193,46 @@ | |||
| 181 | 193 | let local_pending = load_local_pending(conn, node)?; | |
| 182 | 194 | let (clean, conflicts) = detect_conflicts(pulled, &local_pending, node); | |
| 183 | 195 | ||
| 184 | - | let mut resolved: Vec<ChangeEntry> = | |
| 185 | - | clean.gated_at(now, |t, r| lookup_committed(conn, t, r)); | |
| 196 | + | // Site 3: superseded by the committed clock. A stash failure must not | |
| 197 | + | // fail the sync, so it is logged rather than propagated; losing the | |
| 198 | + | // evidence is bad, losing the pull because we could not record the | |
| 199 | + | // evidence is worse. | |
| 200 | + | let mut resolved: Vec<ChangeEntry> = clean.gated_at( | |
| 201 | + | now, | |
| 202 | + | |t, r| lookup_committed(conn, t, r), | |
| 203 | + | |dropped, committed| { | |
| 204 | + | if let Err(e) = stash::stash_superseded(conn, scope, dropped, committed) { | |
| 205 | + | tracing::warn!("could not stash a superseded change: {e}"); | |
| 206 | + | } | |
| 207 | + | }, | |
| 208 | + | ); | |
| 186 | 209 | ||
| 187 | 210 | for pair in conflicts { | |
| 188 | 211 | match resolve_lww_at(&pair.local, &pair.remote, now) { | |
| 189 | - | Resolution::KeepRemote => resolved.push(pair.remote.entry), | |
| 190 | - | Resolution::KeepLocal | Resolution::Skip => {} | |
| 212 | + | // Site 1: our own edit is discarded. | |
| 213 | + | Resolution::KeepRemote => { | |
| 214 | + | stash_or_warn( | |
| 215 | + | conn, | |
| 216 | + | scope, | |
| 217 | + | stash::LosingSide::Local, | |
| 218 | + | &pair.local, | |
| 219 | + | node, | |
| 220 | + | &pair.remote.entry, | |
| 221 | + | ); | |
| 222 | + | resolved.push(pair.remote.entry); | |
| 223 | + | } | |
| 224 | + | // Site 2: the other writer's edit is discarded. | |
| 225 | + | Resolution::KeepLocal | Resolution::Skip => { | |
| 226 | + | stash_or_warn( | |
| 227 | + | conn, | |
| 228 | + | scope, | |
| 229 | + | stash::LosingSide::Remote, | |
| 230 | + | &pair.remote.entry, | |
| 231 | + | pair.remote.device_id, | |
| 232 | + | &pair.local, | |
| 233 | + | ); | |
| 234 | + | } | |
| 235 | + | // A merge keeps both sides' fields, so nothing was thrown away. | |
| 191 | 236 | Resolution::Merged(data) => { | |
| 192 | 237 | let hlc = max_hlc(pair.local.hlc, pair.remote.entry.hlc); | |
| 193 | 238 | resolved.push(ChangeEntry { | |
| @@ -203,11 +248,30 @@ | |||
| 203 | 248 | } | |
| 204 | 249 | } | |
| 205 | 250 | ||
| 251 | + | if let Err(e) = stash::trim_stash(conn) { | |
| 252 | + | tracing::warn!("could not trim the conflict stash: {e}"); | |
| 253 | + | } | |
| 254 | + | ||
| 206 | 255 | Ok(collapse_max_hlc(resolved)) | |
| 207 | 256 | } | |
| 208 | 257 | } | |
| 209 | 258 | } | |
| 210 | 259 | ||
| 260 | + | /// Stash one discarded side, logging rather than failing. Same reasoning as the | |
| 261 | + | /// gate's sink: the stash is evidence about a sync, never a reason to fail one. | |
| 262 | + | fn stash_or_warn( | |
| 263 | + | conn: &Connection, | |
| 264 | + | scope: &str, | |
| 265 | + | side: stash::LosingSide, | |
| 266 | + | losing: &ChangeEntry, | |
| 267 | + | losing_device: DeviceId, | |
| 268 | + | winning: &ChangeEntry, | |
| 269 | + | ) { | |
| 270 | + | if let Err(e) = stash::stash_loser(conn, scope, side, losing, losing_device, winning) { | |
| 271 | + | tracing::warn!("could not stash a discarded change: {e}"); | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 211 | 275 | /// Committed-HLC lookup for the gate. A read error is logged and treated as | |
| 212 | 276 | /// "never applied", the gate then keeps the change, which the idempotent apply | |
| 213 | 277 | /// path can safely re-run. | |
| @@ -372,7 +436,7 @@ | |||
| 372 | 436 | pulled: Vec<PulledChange>, | |
| 373 | 437 | ) { | |
| 374 | 438 | let now = Utc::now(); | |
| 375 | - | let resolved = resolve_pull(conn, s, node, pulled, now).unwrap(); | |
| 439 | + | let resolved = resolve_pull(conn, s, node, pulled, now, "").unwrap(); | |
| 376 | 440 | apply_remote_changes(conn, s, &resolved, "").unwrap(); | |
| 377 | 441 | record_committed(conn, &resolved).unwrap(); | |
| 378 | 442 | } | |
| @@ -453,7 +517,7 @@ | |||
| 453 | 517 | let a_pending = load_local_pending(&a, an); | |
| 454 | 518 | record_committed(&a, &a_pending.unwrap()).unwrap(); | |
| 455 | 519 | // Re-pulling B's OLD change must be gated out (older than committed). | |
| 456 | - | let resolved = resolve_pull(&a, &schema(), an, vec![b_change], Utc::now()).unwrap(); | |
| 520 | + | let resolved = resolve_pull(&a, &schema(), an, vec![b_change], Utc::now(), "").unwrap(); | |
| 457 | 521 | assert!( | |
| 458 | 522 | resolved.iter().all(|e| e.row_id != "r"), | |
| 459 | 523 | "stale re-pull must be gated" | |
| @@ -499,7 +563,7 @@ | |||
| 499 | 563 | let first = local_edit_as_pulled(&src, sn, "r", "first", 999, 1); // higher wall | |
| 500 | 564 | let (src2, sn2) = device(3); | |
| 501 | 565 | let second = local_edit_as_pulled(&src2, sn2, "r", "second", 1, 2); // lower wall, later seq | |
| 502 | - | let resolved = resolve_pull(&a, &s, an, vec![first, second], Utc::now()).unwrap(); | |
| 566 | + | let resolved = resolve_pull(&a, &s, an, vec![first, second], Utc::now(), "").unwrap(); | |
| 503 | 567 | apply_remote_changes(&mut a, &s, &resolved, "").unwrap(); | |
| 504 | 568 | assert_eq!( | |
| 505 | 569 | note_name(&a, "r").as_deref(), | |
| @@ -507,4 +571,357 @@ | |||
| 507 | 571 | "server order: last delivered wins" | |
| 508 | 572 | ); | |
| 509 | 573 | } | |
| 574 | + | ||
| 575 | + | // ── Conflict stash ── | |
| 576 | + | // | |
| 577 | + | // LWW always discards one side; these pin that the discarded bytes are kept | |
| 578 | + | // rather than dropped. The one that matters most is the superseded case, | |
| 579 | + | // which never becomes a ConflictPair and so is invisible to resolve_lww. | |
| 580 | + | ||
| 581 | + | #[derive(Debug, PartialEq)] | |
| 582 | + | struct StashRow { | |
| 583 | + | table_name: String, | |
| 584 | + | row_id: String, | |
| 585 | + | scope: String, | |
| 586 | + | losing_side: String, | |
| 587 | + | losing_payload: Option<String>, | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | fn stash_rows(conn: &Connection) -> Vec<StashRow> { | |
| 591 | + | let mut stmt = conn | |
| 592 | + | .prepare( | |
| 593 | + | "SELECT table_name, row_id, scope, losing_side, losing_payload | |
| 594 | + | FROM sync_conflict_stash ORDER BY id", | |
| 595 | + | ) | |
| 596 | + | .unwrap(); | |
| 597 | + | stmt.query_map([], |r| { | |
| 598 | + | Ok(StashRow { | |
| 599 | + | table_name: r.get(0)?, | |
| 600 | + | row_id: r.get(1)?, | |
| 601 | + | scope: r.get(2)?, | |
| 602 | + | losing_side: r.get(3)?, | |
| 603 | + | losing_payload: r.get(4)?, | |
| 604 | + | }) | |
| 605 | + | }) | |
| 606 | + | .unwrap() | |
| 607 | + | .map(|r| r.unwrap()) | |
| 608 | + | .collect() | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | /// A remote change with an explicit HLC and payload, as a peer would send it. | |
| 612 | + | fn remote_change(from: DeviceId, id: &str, name: &str, hlc: Hlc, seq: i64) -> PulledChange { | |
| 613 | + | PulledChange { | |
| 614 | + | entry: ChangeEntry { | |
| 615 | + | table: "note".to_string(), | |
| 616 | + | op: ChangeOp::Update, | |
| 617 | + | row_id: id.to_string(), | |
| 618 | + | timestamp: Utc::now(), | |
| 619 | + | hlc, | |
| 620 | + | data: Some(serde_json::json!({ "id": id, "name": name })), | |
| 621 | + | extra: serde_json::Map::default(), | |
| 622 | + | }, | |
| 623 | + | device_id: from, | |
| 624 | + | seq, | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | /// Site 1: a newer remote change beats our pending edit, so our edit is the | |
| 629 | + | /// one that vanishes from the row. It must be recoverable. | |
| 630 | + | #[test] | |
| 631 | + | fn stash_keeps_our_own_edit_when_the_remote_wins() { | |
| 632 | + | let (mut conn, n) = device(1); | |
| 633 | + | let peer = node(2); | |
| 634 | + | ||
| 635 | + | // Our pending edit, stamped early so it loses. | |
| 636 | + | conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'mine')", []) | |
| 637 | + | .unwrap(); | |
| 638 | + | stamp_pending(&conn, n, 1_000).unwrap(); | |
| 639 | + | ||
| 640 | + | let newer = Hlc { | |
| 641 | + | wall_ms: 9_000, | |
| 642 | + | counter: 0, | |
| 643 | + | node: peer, | |
| 644 | + | }; | |
| 645 | + | let resolved = resolve_pull( | |
| 646 | + | &conn, | |
| 647 | + | &schema(), | |
| 648 | + | n, | |
| 649 | + | vec![remote_change(peer, "n1", "theirs", newer, 1)], | |
| 650 | + | Utc::now(), | |
| 651 | + | "", | |
| 652 | + | ) | |
| 653 | + | .unwrap(); | |
| 654 | + | apply_remote_changes(&mut conn, &schema(), &resolved, "").unwrap(); | |
| 655 | + | ||
| 656 | + | assert_eq!(note_name(&conn, "n1").as_deref(), Some("theirs")); | |
| 657 | + | let rows = stash_rows(&conn); | |
| 658 | + | assert_eq!( | |
| 659 | + | rows.len(), | |
| 660 | + | 1, | |
| 661 | + | "our discarded edit must be stashed: {rows:?}" | |
| 662 | + | ); | |
| 663 | + | assert_eq!(rows[0].losing_side, "local"); | |
| 664 | + | assert_eq!(rows[0].row_id, "n1"); | |
| 665 | + | assert!( | |
| 666 | + | rows[0].losing_payload.as_deref().unwrap().contains("mine"), | |
| 667 | + | "the stash must hold the discarded value, not a placeholder: {rows:?}" | |
| 668 | + | ); | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | /// Site 2: our pending edit wins, so the other writer's change is discarded. | |
| 672 | + | /// Their bytes are the ones that need keeping. | |
| 673 | + | #[test] | |
| 674 | + | fn stash_keeps_the_remote_edit_when_ours_wins() { | |
| 675 | + | let (conn, n) = device(1); | |
| 676 | + | let peer = node(2); | |
| 677 | + | ||
| 678 | + | conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'mine')", []) | |
| 679 | + | .unwrap(); | |
| 680 | + | stamp_pending(&conn, n, 9_000).unwrap(); | |
| 681 | + | ||
| 682 | + | let older = Hlc { | |
| 683 | + | wall_ms: 1_000, | |
| 684 | + | counter: 0, | |
| 685 | + | node: peer, | |
| 686 | + | }; | |
| 687 | + | let resolved = resolve_pull( | |
| 688 | + | &conn, | |
| 689 | + | &schema(), | |
| 690 | + | n, | |
| 691 | + | vec![remote_change(peer, "n1", "theirs", older, 1)], | |
| 692 | + | Utc::now(), | |
| 693 | + | "", | |
| 694 | + | ) | |
| 695 | + | .unwrap(); | |
| 696 | + | assert!( | |
| 697 | + | resolved.is_empty(), | |
| 698 | + | "the older remote change must not apply" | |
| 699 | + | ); | |
| 700 | + | ||
| 701 | + | let rows = stash_rows(&conn); | |
| 702 | + | assert_eq!( | |
| 703 | + | rows.len(), | |
| 704 | + | 1, | |
| 705 | + | "their discarded edit must be stashed: {rows:?}" | |
| 706 | + | ); | |
| 707 | + | assert_eq!(rows[0].losing_side, "remote"); | |
| 708 | + | assert!( | |
| 709 | + | rows[0] | |
| 710 | + | .losing_payload | |
| 711 | + | .as_deref() | |
| 712 | + | .unwrap() | |
| 713 | + | .contains("theirs") | |
| 714 | + | ); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | /// Site 3, the quiet one: no local pending edit contests the row, so no | |
| 718 | + | /// ConflictPair is ever built. The change is dropped by the committed-HLC | |
| 719 | + | /// gate alone, which is why a stash wired only into the match arms misses it. | |
| 720 | + | #[test] | |
| 721 | + | fn stash_keeps_a_change_superseded_by_the_committed_clock() { | |
| 722 | + | let (mut conn, n) = device(1); | |
| 723 | + | let peer = node(2); | |
| 724 | + | ||
| 725 | + | // Apply and commit a newer value, with nothing left pending afterwards. | |
| 726 | + | let newer = Hlc { | |
| 727 | + | wall_ms: 9_000, | |
| 728 | + | counter: 0, | |
| 729 | + | node: peer, | |
| 730 | + | }; | |
| 731 | + | let resolved = resolve_pull( | |
| 732 | + | &conn, | |
| 733 | + | &schema(), | |
| 734 | + | n, | |
| 735 | + | vec![remote_change(peer, "n1", "current", newer, 1)], | |
| 736 | + | Utc::now(), | |
| 737 | + | "", | |
| 738 | + | ) | |
| 739 | + | .unwrap(); | |
| 740 | + | apply_remote_changes(&mut conn, &schema(), &resolved, "").unwrap(); | |
| 741 | + | record_committed(&conn, &resolved).unwrap(); | |
| 742 | + | assert!(stash_rows(&conn).is_empty(), "nothing lost yet"); | |
| 743 | + | ||
| 744 | + | // Now pull an older change for the same row. No pending edit contests it. | |
| 745 | + | let older = Hlc { | |
| 746 | + | wall_ms: 1_000, | |
| 747 | + | counter: 0, | |
| 748 | + | node: peer, | |
| 749 | + | }; | |
| 750 | + | let resolved = resolve_pull( | |
| 751 | + | &conn, | |
| 752 | + | &schema(), | |
| 753 | + | n, | |
| 754 | + | vec![remote_change(peer, "n1", "stale", older, 2)], | |
| 755 | + | Utc::now(), | |
| 756 | + | "", | |
| 757 | + | ) | |
| 758 | + | .unwrap(); | |
| 759 | + | assert!(resolved.is_empty(), "the superseded change must not apply"); | |
| 760 | + | assert_eq!(note_name(&conn, "n1").as_deref(), Some("current")); | |
| 761 | + | ||
| 762 | + | let rows = stash_rows(&conn); | |
| 763 | + | assert_eq!( | |
| 764 | + | rows.len(), | |
| 765 | + | 1, | |
| 766 | + | "the superseded change must be stashed: {rows:?}" | |
| 767 | + | ); | |
| 768 | + | assert_eq!(rows[0].losing_side, "remote"); | |
| 769 | + | assert!(rows[0].losing_payload.as_deref().unwrap().contains("stale")); | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | /// An echo carries the same bytes as the winner, so nothing was lost. Without | |
| 773 | + | /// this the stash fills with no-ops and stops being worth reading. | |
| 774 | + | #[test] | |
| 775 | + | fn stash_ignores_a_conflict_whose_payloads_are_identical() { | |
| 776 | + | let (conn, n) = device(1); | |
| 777 | + | let peer = node(2); | |
| 778 | + | ||
| 779 | + | conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'same')", []) | |
| 780 | + | .unwrap(); | |
| 781 | + | stamp_pending(&conn, n, 1_000).unwrap(); | |
| 782 | + | let local = load_local_pending(&conn, n).unwrap().pop().unwrap(); | |
| 783 | + | ||
| 784 | + | // Byte-identical payload, newer clock: the remote wins and our identical | |
| 785 | + | // value goes away, which costs nothing. | |
| 786 | + | let mut remote = remote_change( | |
| 787 | + | peer, | |
| 788 | + | "n1", | |
| 789 | + | "x", | |
| 790 | + | Hlc { | |
| 791 | + | wall_ms: 9_000, | |
| 792 | + | counter: 0, | |
| 793 | + | node: peer, | |
| 794 | + | }, | |
| 795 | + | 1, | |
| 796 | + | ); | |
| 797 | + | remote.entry.data = local.data.clone(); | |
| 798 | + | ||
| 799 | + | resolve_pull(&conn, &schema(), n, vec![remote], Utc::now(), "").unwrap(); | |
| 800 | + | assert!( | |
| 801 | + | stash_rows(&conn).is_empty(), | |
| 802 | + | "an identical payload is not a lost edit: {:?}", | |
| 803 | + | stash_rows(&conn) | |
| 804 | + | ); | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | /// A clock-poisoned entry is refused as hostile, not outvoted. Stashing it | |
| 808 | + | /// would hand whoever sent it a way to fill the stash. | |
| 809 | + | #[test] | |
| 810 | + | fn stash_ignores_a_clock_poisoned_drop() { | |
| 811 | + | let (conn, n) = device(1); | |
| 812 | + | let peer = node(2); | |
| 813 | + | let now = Utc::now(); | |
| 814 | + | let poisoned = Hlc { | |
| 815 | + | wall_ms: now.timestamp_millis() + crate::conflict::MAX_HLC_DRIFT_MS * 10, | |
| 816 | + | counter: 0, | |
| 817 | + | node: peer, | |
| 818 | + | }; | |
| 819 | + | ||
| 820 | + | let resolved = resolve_pull( | |
| 821 | + | &conn, | |
| 822 | + | &schema(), | |
| 823 | + | n, | |
| 824 | + | vec![remote_change(peer, "n1", "from the future", poisoned, 1)], | |
| 825 | + | now, | |
| 826 | + | "", | |
| 827 | + | ) | |
| 828 | + | .unwrap(); | |
| 829 | + | assert!(resolved.is_empty(), "a poisoned entry must not apply"); | |
| 830 | + | assert!( | |
| 831 | + | stash_rows(&conn).is_empty(), | |
| 832 | + | "a poisoned entry is refused, not outvoted: {:?}", | |
| 833 | + | stash_rows(&conn) | |
| 834 | + | ); | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | /// The scope a loss happened in is recorded, so a consuming app can tell a | |
| 838 | + | /// personal conflict from one inside a shared group. | |
| 839 | + | #[test] | |
| 840 | + | fn stash_records_the_scope() { | |
| 841 | + | let (conn, n) = device(1); | |
| 842 | + | let peer = node(2); | |
| 843 | + | ||
| 844 | + | conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'mine')", []) | |
| 845 | + | .unwrap(); | |
| 846 | + | stamp_pending(&conn, n, 9_000).unwrap(); | |
| 847 | + | ||
| 848 | + | let older = Hlc { | |
| 849 | + | wall_ms: 1_000, | |
| 850 | + | counter: 0, | |
| 851 | + | node: peer, | |
| 852 | + | }; | |
| 853 | + | resolve_pull( | |
| 854 | + | &conn, | |
| 855 | + | &schema(), | |
| 856 | + | n, | |
| 857 | + | vec![remote_change(peer, "n1", "theirs", older, 1)], | |
| 858 | + | Utc::now(), | |
| 859 | + | "group-7", | |
| 860 | + | ) | |
| 861 | + | .unwrap(); | |
| 862 | + | ||
| 863 | + | assert_eq!(stash_rows(&conn)[0].scope, "group-7"); | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | /// ServerOrder does not compare versions, so it has no loser to name. | |
| 867 | + | #[test] | |
| 868 | + | fn server_order_stashes_nothing() { | |
| 869 | + | let (mut conn, n) = device(1); | |
| 870 | + | let peer = node(2); | |
| 871 | + | let s = server_order_schema(); | |
| 872 | + | ||
| 873 | + | conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'mine')", []) | |
| 874 | + | .unwrap(); | |
| 875 | + | stamp_pending(&conn, n, 9_000).unwrap(); | |
| 876 | + | ||
| 877 | + | let older = Hlc { | |
| 878 | + | wall_ms: 1_000, | |
| 879 | + | counter: 0, | |
| 880 | + | node: peer, | |
| 881 | + | }; | |
| 882 | + | let resolved = resolve_pull( | |
| 883 | + | &conn, | |
| 884 | + | &s, | |
| 885 | + | n, | |
| 886 | + | vec![remote_change(peer, "n1", "theirs", older, 1)], | |
| 887 | + | Utc::now(), | |
| 888 | + | "", | |
| 889 | + | ) | |
| 890 | + | .unwrap(); | |
| 891 | + | apply_remote_changes(&mut conn, &s, &resolved, "").unwrap(); | |
| 892 | + | ||
| 893 | + | assert!(stash_rows(&conn).is_empty()); | |
| 894 | + | } | |
| 895 | + | ||
| 896 | + | /// The stash is bounded. Unbounded, a pathological sync loop grows it without | |
| 897 | + | /// limit. | |
| 898 | + | #[test] | |
| 899 | + | fn stash_is_trimmed_to_its_ceiling() { | |
| 900 | + | let (conn, _) = device(1); | |
| 901 | + | for i in 0..(super::stash::MAX_STASH_ROWS + 50) { | |
| 902 | + | conn.execute( | |
| 903 | + | "INSERT INTO sync_conflict_stash | |
| 904 | + | (table_name, row_id, losing_side, losing_payload, losing_hlc, losing_device, winning_hlc) | |
| 905 | + | VALUES ('note', ?1, 'remote', '{}', '1:0:x', 'dev', '2:0:y')", | |
| 906 | + | [i.to_string()], | |
| 907 | + | ) | |
| 908 | + | .unwrap(); | |
| 909 | + | } | |
| 910 | + | super::stash::trim_stash(&conn).unwrap(); | |
| 911 | + | ||
| 912 | + | let kept: i64 = conn | |
| 913 | + | .query_row("SELECT COUNT(*) FROM sync_conflict_stash", [], |r| r.get(0)) | |
| 914 | + | .unwrap(); | |
| 915 | + | assert_eq!(kept, super::stash::MAX_STASH_ROWS); | |
| 916 | + | ||
| 917 | + | // The newest survive: the oldest losses are the least likely to be acted on. | |
| 918 | + | let oldest_kept: String = conn | |
| 919 | + | .query_row( | |
| 920 | + | "SELECT row_id FROM sync_conflict_stash ORDER BY id ASC LIMIT 1", | |
| 921 | + | [], | |
| 922 | + | |r| r.get(0), | |
| 923 | + | ) | |
| 924 | + | .unwrap(); | |
| 925 | + | assert_eq!(oldest_kept, "50"); | |
| 926 | + | } | |
| 510 | 927 | } |
| @@ -68,6 +68,38 @@ | |||
| 68 | 68 | hlc_node TEXT NOT NULL, | |
| 69 | 69 | PRIMARY KEY (table_name, row_id) | |
| 70 | 70 | ) WITHOUT ROWID; | |
| 71 | + | ||
| 72 | + | -- Every version last-write-wins threw away, kept instead of dropped. | |
| 73 | + | -- | |
| 74 | + | -- LWW picks a winner and the loser's bytes are gone: the local user's edit leaves | |
| 75 | + | -- the row, or the other writer's does. Multi-user editing is not a first-class | |
| 76 | + | -- feature here and is not becoming one, so this is the safety net under it rather | |
| 77 | + | -- than a merge mechanism. A consuming app decides how (and whether) to surface a | |
| 78 | + | -- row; nothing in the engine reads them back. | |
| 79 | + | -- | |
| 80 | + | -- Local-only by construction: absent from every sync manifest, so it is never | |
| 81 | + | -- group-scoped, never pushed, and never rides a shared changelog. A stash is | |
| 82 | + | -- per-device evidence about a decision this device made, not shared state. | |
| 83 | + | CREATE TABLE IF NOT EXISTS sync_conflict_stash ( | |
| 84 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 85 | + | table_name TEXT NOT NULL, | |
| 86 | + | row_id TEXT NOT NULL, | |
| 87 | + | -- '' = personal, otherwise the group id, matching sync_changelog.scope. | |
| 88 | + | scope TEXT NOT NULL DEFAULT '', | |
| 89 | + | -- 'local' = this device's edit lost, 'remote' = the other writer's did. | |
| 90 | + | losing_side TEXT NOT NULL CHECK (losing_side IN ('local', 'remote')), | |
| 91 | + | -- The discarded payload, decrypted, as it would have been applied. | |
| 92 | + | losing_payload TEXT, | |
| 93 | + | losing_hlc TEXT NOT NULL, | |
| 94 | + | losing_device TEXT NOT NULL, | |
| 95 | + | winning_hlc TEXT NOT NULL, | |
| 96 | + | detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 97 | + | -- Set by a consuming app once a human has looked at this. Never set by the | |
| 98 | + | -- engine. | |
| 99 | + | reviewed_at TEXT | |
| 100 | + | ); | |
| 101 | + | CREATE INDEX IF NOT EXISTS idx_sync_conflict_stash_row | |
| 102 | + | ON sync_conflict_stash(table_name, row_id); | |
| 71 | 103 | "; | |
| 72 | 104 | ||
| 73 | 105 | /// Provisioned once, never synced. Only emitted when a table hashes its row-id. | |
| @@ -605,4 +637,46 @@ | |||
| 605 | 637 | let plain = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]); | |
| 606 | 638 | assert!(!plain.migration_sql().contains("row_id_salt")); | |
| 607 | 639 | } | |
| 640 | + | ||
| 641 | + | /// The conflict stash is bookkeeping, not synced data. It must get the table | |
| 642 | + | /// and no triggers: a trigger would put a device's discarded plaintext into | |
| 643 | + | /// the changelog, which is exactly what "local-only by construction" rules | |
| 644 | + | /// out. Consumers are additionally responsible for keeping the name out of | |
| 645 | + | /// their own manifests. | |
| 646 | + | #[test] | |
| 647 | + | fn conflict_stash_is_created_but_never_synced() { | |
| 648 | + | let sql = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]).migration_sql(); | |
| 649 | + | ||
| 650 | + | assert!(sql.contains("CREATE TABLE IF NOT EXISTS sync_conflict_stash")); | |
| 651 | + | assert!( | |
| 652 | + | !sql.contains("sync_conflict_stash_ai") | |
| 653 | + | && !sql.contains("AFTER INSERT ON sync_conflict_stash"), | |
| 654 | + | "a trigger on the stash would push discarded plaintext to the server" | |
| 655 | + | ); | |
| 656 | + | ||
| 657 | + | // And it is reachable: the DDL runs and the shape is what the writer binds. | |
| 658 | + | let conn = rusqlite::Connection::open_in_memory().unwrap(); | |
| 659 | + | conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);") | |
| 660 | + | .unwrap(); | |
| 661 | + | conn.execute_batch(&sql).unwrap(); | |
| 662 | + | conn.execute( | |
| 663 | + | "INSERT INTO sync_conflict_stash | |
| 664 | + | (table_name, row_id, losing_side, losing_payload, losing_hlc, losing_device, winning_hlc) | |
| 665 | + | VALUES ('note', 'r1', 'remote', '{}', '1:0:a', 'dev', '2:0:b')", | |
| 666 | + | [], | |
| 667 | + | ) | |
| 668 | + | .unwrap(); | |
| 669 | + | ||
| 670 | + | // losing_side is constrained: a typo must fail loudly rather than produce | |
| 671 | + | // a row nothing can classify. | |
| 672 | + | assert!( | |
| 673 | + | conn.execute( | |
| 674 | + | "INSERT INTO sync_conflict_stash | |
| 675 | + | (table_name, row_id, losing_side, losing_hlc, losing_device, winning_hlc) | |
| 676 | + | VALUES ('note', 'r2', 'neither', '1:0:a', 'dev', '2:0:b')", | |
| 677 | + | [], | |
| 678 | + | ) | |
| 679 | + | .is_err() | |
| 680 | + | ); | |
| 681 | + | } | |
| 608 | 682 | } |
| @@ -27,6 +27,7 @@ | |||
| 27 | 27 | pub mod migrate; | |
| 28 | 28 | pub mod scheduler; | |
| 29 | 29 | pub mod schema; | |
| 30 | + | pub(crate) mod stash; | |
| 30 | 31 | pub mod sync; | |
| 31 | 32 | ||
| 32 | 33 | pub use apply::{ApplyOutcome, Unapplied, apply_remote_changes}; |
| @@ -199,18 +199,16 @@ | |||
| 199 | 199 | cursor: i64, | |
| 200 | 200 | ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send { | |
| 201 | 201 | async move { | |
| 202 | - | let gck = self.group_content_key(group_id, gck_version).await?; | |
| 203 | 202 | match self | |
| 204 | - | .group_pull_rich(group_id, &gck, device_id, cursor) | |
| 203 | + | .group_pull_rich(group_id, gck_version, device_id, cursor) | |
| 205 | 204 | .await | |
| 206 | 205 | { | |
| 207 | 206 | // A decrypt failure means this client is holding a stale GCK (it | |
| 208 | - | // missed a rotation): drop it, re-fetch the current grant, retry | |
| 207 | + | // missed a rotation): drop every cached generation, re-fetch, retry | |
| 209 | 208 | // once. A second failure surfaces. | |
| 210 | 209 | Err(SyncKitError::DecryptionFailed) => { | |
| 211 | 210 | self.invalidate_gck(group_id); | |
| 212 | - | let gck = self.group_content_key(group_id, gck_version).await?; | |
| 213 | - | self.group_pull_rich(group_id, &gck, device_id, cursor) | |
| 211 | + | self.group_pull_rich(group_id, gck_version, device_id, cursor) | |
| 214 | 212 | .await | |
| 215 | 213 | } | |
| 216 | 214 | other => other, | |
| @@ -536,7 +534,7 @@ | |||
| 536 | 534 | let mut all = retry; | |
| 537 | 535 | all.extend(pulled); | |
| 538 | 536 | ||
| 539 | - | let resolved = resolve_pull(conn, schema, device_id, all, Utc::now())?; | |
| 537 | + | let resolved = resolve_pull(conn, schema, device_id, all, Utc::now(), scope_key)?; | |
| 540 | 538 | let outcome = apply_remote_changes(conn, schema, &resolved, scope_key)?; | |
| 541 | 539 | ||
| 542 | 540 | // Only what actually landed advances the committed ledger. Recording an |
| @@ -1,0 +1,166 @@ | |||
| 1 | + | //! The conflict stash: every version last-write-wins threw away, kept. | |
| 2 | + | //! | |
| 3 | + | //! LWW always discards one side. Which side is a detail of clock order, so from | |
| 4 | + | //! the user's seat the outcome is the same either way: an edit somebody made is | |
| 5 | + | //! gone, with no record that it existed. Multi-user editing is not a first-class | |
| 6 | + | //! feature in SyncKit and is not becoming one; this is the safety net under it. | |
| 7 | + | //! | |
| 8 | + | //! Local-only by construction. `sync_conflict_stash` is absent from every sync | |
| 9 | + | //! manifest, so it is never group-scoped, never pushed, and never rides a shared | |
| 10 | + | //! changelog. A stash row is per-device evidence about a decision this device | |
| 11 | + | //! made, not shared state, and pushing it would leak one member's discarded | |
| 12 | + | //! plaintext into a group log. | |
| 13 | + | //! | |
| 14 | + | //! Nothing in the engine reads these rows back. A consuming app decides whether | |
| 15 | + | //! and how to surface them (a conflicts view, a badge on the row, an annotation | |
| 16 | + | //! in context) and when to mark one reviewed. | |
| 17 | + | //! | |
| 18 | + | //! Design: wiki synckit-groups-design. | |
| 19 | + | ||
| 20 | + | use rusqlite::Connection; | |
| 21 | + | ||
| 22 | + | use crate::conflict::canonical_payload; | |
| 23 | + | use crate::error::Result; | |
| 24 | + | use crate::types::{ChangeEntry, Hlc, PulledChange}; | |
| 25 | + | ||
| 26 | + | /// Which side of the contest lost, as stored in `losing_side`. | |
| 27 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 28 | + | pub(crate) enum LosingSide { | |
| 29 | + | /// This device's own edit was discarded: a remote change won. | |
| 30 | + | Local, | |
| 31 | + | /// The other writer's edit was discarded: our value stood. | |
| 32 | + | Remote, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl LosingSide { | |
| 36 | + | fn as_str(self) -> &'static str { | |
| 37 | + | match self { | |
| 38 | + | LosingSide::Local => "local", | |
| 39 | + | LosingSide::Remote => "remote", | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// How many stash rows to keep per device before the oldest are trimmed. | |
| 45 | + | /// | |
| 46 | + | /// The stash is evidence a human might read, not an audit log. Unbounded, a | |
| 47 | + | /// pathological sync loop between two devices would grow it without limit; at | |
| 48 | + | /// this size it stays small next to the changelog and still holds far more than | |
| 49 | + | /// anyone will review. | |
| 50 | + | pub(crate) const MAX_STASH_ROWS: i64 = 1_000; | |
| 51 | + | ||
| 52 | + | /// Format an HLC for storage. Sortable and human-legible, so a stash row can be | |
| 53 | + | /// ordered and read without decoding. | |
| 54 | + | fn hlc_text(hlc: &Hlc) -> String { | |
| 55 | + | format!("{}:{}:{}", hlc.wall_ms, hlc.counter, hlc.node) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Record one discarded version. | |
| 59 | + | /// | |
| 60 | + | /// Returns `Ok(false)` without writing when the two payloads are byte-identical | |
| 61 | + | /// under [`canonical_payload`], the comparison [`crate::conflict::resolve_lww`] | |
| 62 | + | /// already uses for its exact-HLC tiebreak. Every echo and every unchanged | |
| 63 | + | /// re-save would otherwise stash, and a table full of no-ops is one nobody reads. | |
| 64 | + | pub(crate) fn stash_loser( | |
| 65 | + | conn: &Connection, | |
| 66 | + | scope: &str, | |
| 67 | + | side: LosingSide, | |
| 68 | + | losing: &ChangeEntry, | |
| 69 | + | losing_device: crate::ids::DeviceId, | |
| 70 | + | winning: &ChangeEntry, | |
| 71 | + | ) -> Result<bool> { | |
| 72 | + | if canonical_payload(losing.data.as_ref()) == canonical_payload(winning.data.as_ref()) { | |
| 73 | + | return Ok(false); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | let payload = losing | |
| 77 | + | .data | |
| 78 | + | .as_ref() | |
| 79 | + | .map(serde_json::to_string) | |
| 80 | + | .transpose() | |
| 81 | + | .map_err(|e| crate::error::SyncKitError::Internal(format!("stash payload: {e}")))?; | |
| 82 | + | ||
| 83 | + | conn.execute( | |
| 84 | + | "INSERT INTO sync_conflict_stash | |
| 85 | + | (table_name, row_id, scope, losing_side, losing_payload, losing_hlc, losing_device, winning_hlc) | |
| 86 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 87 | + | rusqlite::params![ | |
| 88 | + | losing.table, | |
| 89 | + | losing.row_id, | |
| 90 | + | scope, | |
| 91 | + | side.as_str(), | |
| 92 | + | payload, | |
| 93 | + | hlc_text(&losing.hlc), | |
| 94 | + | losing_device.to_string(), | |
| 95 | + | hlc_text(&winning.hlc), | |
| 96 | + | ], | |
| 97 | + | )?; | |
| 98 | + | ||
| 99 | + | tracing::debug!( | |
| 100 | + | table = %losing.table, | |
| 101 | + | row_id = %losing.row_id, | |
| 102 | + | side = side.as_str(), | |
| 103 | + | "stashed the losing side of a conflict" | |
| 104 | + | ); | |
| 105 | + | Ok(true) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Record a remote change the committed-HLC gate discarded. | |
| 109 | + | /// | |
| 110 | + | /// This is the quiet loss: no [`crate::conflict::ConflictPair`] is ever built for | |
| 111 | + | /// it, because no local *pending* edit contests the row. It happens when this | |
| 112 | + | /// device already applied and pushed a newer edit and then pulls an older remote | |
| 113 | + | /// one, which means the other writer's edit is dropped without anything looking | |
| 114 | + | /// like a conflict. There is no losing `ChangeEntry` to compare against, only the | |
| 115 | + | /// committed clock, so the payload-identity check cannot apply here. | |
| 116 | + | pub(crate) fn stash_superseded( | |
| 117 | + | conn: &Connection, | |
| 118 | + | scope: &str, | |
| 119 | + | dropped: &PulledChange, | |
| 120 | + | committed: &Hlc, | |
| 121 | + | ) -> Result<()> { | |
| 122 | + | let payload = dropped | |
| 123 | + | .entry | |
| 124 | + | .data | |
| 125 | + | .as_ref() | |
| 126 | + | .map(serde_json::to_string) | |
| 127 | + | .transpose() | |
| 128 | + | .map_err(|e| crate::error::SyncKitError::Internal(format!("stash payload: {e}")))?; | |
| 129 | + | ||
| 130 | + | conn.execute( | |
| 131 | + | "INSERT INTO sync_conflict_stash | |
| 132 | + | (table_name, row_id, scope, losing_side, losing_payload, losing_hlc, losing_device, winning_hlc) | |
| 133 | + | VALUES (?1, ?2, ?3, 'remote', ?4, ?5, ?6, ?7)", | |
| 134 | + | rusqlite::params![ | |
| 135 | + | dropped.entry.table, | |
| 136 | + | dropped.entry.row_id, | |
| 137 | + | scope, | |
| 138 | + | payload, | |
| 139 | + | hlc_text(&dropped.entry.hlc), | |
| 140 | + | dropped.device_id.to_string(), | |
| 141 | + | hlc_text(committed), | |
| 142 | + | ], | |
| 143 | + | )?; | |
| 144 | + | ||
| 145 | + | tracing::debug!( | |
| 146 | + | table = %dropped.entry.table, | |
| 147 | + | row_id = %dropped.entry.row_id, | |
| 148 | + | "stashed a remote change superseded by the committed HLC" | |
| 149 | + | ); | |
| 150 | + | Ok(()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Trim the stash to [`MAX_STASH_ROWS`], oldest first. Reviewed rows are trimmed | |
| 154 | + | /// like any other: marking one reviewed says a human saw it, not that it must be | |
| 155 | + | /// kept forever. | |
| 156 | + | pub(crate) fn trim_stash(conn: &Connection) -> Result<usize> { | |
| 157 | + | let removed = conn.execute( | |
| 158 | + | "DELETE FROM sync_conflict_stash WHERE id NOT IN | |
| 159 | + | (SELECT id FROM sync_conflict_stash ORDER BY id DESC LIMIT ?1)", | |
| 160 | + | rusqlite::params![MAX_STASH_ROWS], | |
| 161 | + | )?; | |
| 162 | + | if removed > 0 { | |
| 163 | + | tracing::debug!(removed, "trimmed the conflict stash"); | |
| 164 | + | } | |
| 165 | + | Ok(removed) | |
| 166 | + | } |