Skip to main content

suture_protocol/
lib.rs

1#![allow(clippy::collapsible_match)]
2//! Suture Protocol — wire format for client-server communication.
3//!
4//! Defines the request/response types used by the Suture Hub for
5//! push, pull, authentication, and repository management operations.
6//! All types are serializable via `serde` for JSON transport.
7
8use serde::{Deserialize, Serialize};
9
10pub const PROTOCOL_VERSION: u32 = 1;
11pub const PROTOCOL_VERSION_V2: u32 = 2;
12
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct HandshakeRequest {
15    pub client_version: u32,
16    pub client_name: String,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize)]
20pub struct HandshakeResponse {
21    pub server_version: u32,
22    pub server_name: String,
23    pub compatible: bool,
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub enum AuthMethod {
28    None,
29    Signature {
30        public_key: String,
31        signature: String,
32    },
33    Token(String),
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct AuthRequest {
38    pub method: AuthMethod,
39    pub timestamp: u64,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
43pub struct HashProto {
44    pub value: String,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct PatchProto {
49    pub id: HashProto,
50    pub operation_type: String,
51    pub touch_set: Vec<String>,
52    pub target_path: Option<String>,
53    pub payload: String,
54    pub parent_ids: Vec<HashProto>,
55    pub author: String,
56    pub message: String,
57    pub timestamp: u64,
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct BranchProto {
62    pub name: String,
63    pub target_id: HashProto,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize)]
67pub struct BlobRef {
68    pub hash: HashProto,
69    pub data: String,
70    #[serde(default)]
71    pub truncated: bool,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75pub struct PushRequest {
76    pub repo_id: String,
77    pub patches: Vec<PatchProto>,
78    pub branches: Vec<BranchProto>,
79    pub blobs: Vec<BlobRef>,
80    /// Optional Ed25519 signature (64 bytes, base64-encoded).
81    /// Required when the hub has authorized keys configured.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub signature: Option<Vec<u8>>,
84    /// Client's known state of branches at time of push.
85    /// Used for fast-forward validation on the hub.
86    /// Optional for backward compatibility.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub known_branches: Option<Vec<BranchProto>>,
89    /// If true, skip fast-forward validation on push.
90    #[serde(default)]
91    pub force: bool,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct PushResponse {
96    pub success: bool,
97    pub error: Option<String>,
98    pub existing_patches: Vec<HashProto>,
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct PullRequest {
103    pub repo_id: String,
104    pub known_branches: Vec<BranchProto>,
105    /// Limit the number of patches returned from each branch tip.
106    /// None = full history, Some(n) = last n patches per branch.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub max_depth: Option<u32>,
109}
110
111#[derive(Debug, Serialize, Deserialize)]
112pub struct PullResponse {
113    pub success: bool,
114    pub error: Option<String>,
115    pub patches: Vec<PatchProto>,
116    pub branches: Vec<BranchProto>,
117    pub blobs: Vec<BlobRef>,
118}
119
120#[derive(Debug, Serialize, Deserialize)]
121pub struct ListReposResponse {
122    pub repo_ids: Vec<String>,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
126pub struct RepoInfoResponse {
127    pub repo_id: String,
128    pub patch_count: u64,
129    pub branches: Vec<BranchProto>,
130    pub success: bool,
131    pub error: Option<String>,
132}
133
134pub fn hash_to_hex(h: &HashProto) -> String {
135    h.value.clone()
136}
137
138pub fn compress(data: &[u8]) -> Result<Vec<u8>, String> {
139    zstd::encode_all(data, 3).map_err(|e| format!("zstd compression failed: {e}"))
140}
141
142pub fn decompress(data: &[u8]) -> Result<Vec<u8>, String> {
143    zstd::decode_all(data).map_err(|e| format!("zstd decompression failed: {e}"))
144}
145
146pub fn hex_to_hash(hex: &str) -> HashProto {
147    HashProto {
148        value: hex.to_string(),
149    }
150}
151
152/// Build canonical bytes for push request signing.
153/// Format: repo_id \0 patch_count \0 (each patch: id \0 op \0 author \0 msg \0 timestamp \0) ... branch_count \0 (each: name \0 target \0) ...
154pub fn canonical_push_bytes(req: &PushRequest) -> Vec<u8> {
155    let mut buf = Vec::new();
156
157    buf.extend_from_slice(req.repo_id.as_bytes());
158    buf.push(0);
159
160    buf.extend_from_slice(&(req.patches.len() as u64).to_le_bytes());
161    for patch in &req.patches {
162        buf.extend_from_slice(patch.id.value.as_bytes());
163        buf.push(0);
164        buf.extend_from_slice(patch.operation_type.as_bytes());
165        buf.push(0);
166        buf.extend_from_slice(patch.author.as_bytes());
167        buf.push(0);
168        buf.extend_from_slice(patch.message.as_bytes());
169        buf.push(0);
170        buf.extend_from_slice(&patch.timestamp.to_le_bytes());
171        buf.push(0);
172    }
173
174    buf.extend_from_slice(&(req.branches.len() as u64).to_le_bytes());
175    for branch in &req.branches {
176        buf.extend_from_slice(branch.name.as_bytes());
177        buf.push(0);
178        buf.extend_from_slice(branch.target_id.value.as_bytes());
179        buf.push(0);
180    }
181
182    buf
183}
184
185#[derive(Clone, Debug, Serialize, Deserialize)]
186pub enum DeltaEncoding {
187    BinaryPatch,
188    FullBlob,
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
192pub struct BlobDelta {
193    pub base_hash: HashProto,
194    pub target_hash: HashProto,
195    pub encoding: DeltaEncoding,
196    pub delta_data: String,
197}
198
199#[derive(Clone, Debug, Serialize, Deserialize)]
200pub struct ClientCapabilities {
201    pub supports_delta: bool,
202    pub supports_compression: bool,
203    pub max_blob_size: u64,
204}
205
206#[derive(Clone, Debug, Serialize, Deserialize)]
207pub struct ServerCapabilities {
208    pub supports_delta: bool,
209    pub supports_compression: bool,
210    pub max_blob_size: u64,
211    pub protocol_versions: Vec<u32>,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
215pub struct PullRequestV2 {
216    pub repo_id: String,
217    pub known_branches: Vec<BranchProto>,
218    pub max_depth: Option<u32>,
219    pub known_blob_hashes: Vec<HashProto>,
220    pub capabilities: ClientCapabilities,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
224pub struct PullResponseV2 {
225    pub success: bool,
226    pub error: Option<String>,
227    pub patches: Vec<PatchProto>,
228    pub branches: Vec<BranchProto>,
229    pub blobs: Vec<BlobRef>,
230    pub deltas: Vec<BlobDelta>,
231    pub protocol_version: u32,
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235pub struct PushRequestV2 {
236    pub repo_id: String,
237    pub patches: Vec<PatchProto>,
238    pub branches: Vec<BranchProto>,
239    pub blobs: Vec<BlobRef>,
240    pub deltas: Vec<BlobDelta>,
241    pub signature: Option<Vec<u8>>,
242    pub known_branches: Option<Vec<BranchProto>>,
243    pub force: bool,
244}
245
246#[derive(Clone, Debug, Serialize, Deserialize)]
247pub struct HandshakeRequestV2 {
248    pub client_version: u32,
249    pub client_name: String,
250    pub capabilities: ClientCapabilities,
251}
252
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct HandshakeResponseV2 {
255    pub server_version: u32,
256    pub server_name: String,
257    pub compatible: bool,
258    pub server_capabilities: ServerCapabilities,
259}
260
261// === LFS Protocol Types ===
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct LfsBatchRequest {
265    pub repo_id: String,
266    pub objects: Vec<LfsObjectRef>,
267    pub operation: LfsOperation,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "lowercase")]
272pub enum LfsOperation {
273    Upload,
274    Download,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct LfsObjectRef {
279    pub oid: String,
280    pub size: u64,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct LfsBatchResponse {
285    pub objects: Vec<LfsObjectAction>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub token: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct LfsObjectAction {
292    pub oid: String,
293    pub size: u64,
294    pub action: LfsAction,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub href: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub header: Option<std::collections::HashMap<String, String>>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "lowercase")]
303pub enum LfsAction {
304    None,
305    Upload,
306    Download,
307    Error,
308}
309
310pub fn compute_delta(base: &[u8], target: &[u8]) -> (Vec<u8>, Vec<u8>) {
311    let prefix_len = base
312        .iter()
313        .zip(target.iter())
314        .take_while(|(a, b)| a == b)
315        .count();
316
317    let max_suffix_base = base.len().saturating_sub(prefix_len);
318    let max_suffix_target = target.len().saturating_sub(prefix_len);
319    let suffix_len = base[prefix_len..]
320        .iter()
321        .rev()
322        .zip(target[prefix_len..].iter().rev())
323        .take_while(|(a, b)| a == b)
324        .count()
325        .min(max_suffix_base)
326        .min(max_suffix_target);
327
328    let changed_start = prefix_len;
329    let changed_end_target = target.len().saturating_sub(suffix_len);
330    let changed = &target[changed_start..changed_end_target];
331
332    if changed.len() < target.len() {
333        let mut delta = Vec::new();
334        delta.push(0x01);
335        delta.extend_from_slice(&(prefix_len as u64).to_le_bytes());
336        delta.extend_from_slice(&(suffix_len as u64).to_le_bytes());
337        delta.extend_from_slice(&(target.len() as u64).to_le_bytes());
338        delta.extend_from_slice(changed);
339        (base.to_vec(), delta)
340    } else {
341        let mut full = vec![0x00];
342        full.extend_from_slice(target);
343        (base.to_vec(), full)
344    }
345}
346
347pub fn apply_delta(base: &[u8], delta: &[u8]) -> Vec<u8> {
348    if delta.is_empty() {
349        return Vec::new();
350    }
351    match delta[0] {
352        0x00 => delta[1..].to_vec(),
353        0x01 => {
354            if delta.len() < 25 {
355                return delta.to_vec();
356            }
357            let prefix_len = u64::from_le_bytes(delta[1..9].try_into().unwrap_or([0; 8])) as usize;
358            let suffix_len = u64::from_le_bytes(delta[9..17].try_into().unwrap_or([0; 8])) as usize;
359            let total_len = u64::from_le_bytes(delta[17..25].try_into().unwrap_or([0; 8])) as usize;
360            let changed = &delta[25..];
361
362            let mut result = Vec::with_capacity(total_len);
363            result.extend_from_slice(&base[..prefix_len.min(base.len())]);
364            result.extend_from_slice(changed);
365            result.extend_from_slice(&base[base.len().saturating_sub(suffix_len)..]);
366            result
367        }
368        _ => delta.to_vec(),
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn roundtrip<T: Serialize + for<'de> Deserialize<'de>>(val: &T) -> T {
377        let json = serde_json::to_string(val).expect("serialize");
378        serde_json::from_str(&json).expect("deserialize")
379    }
380
381    fn make_hash(hex: &str) -> HashProto {
382        HashProto {
383            value: hex.to_string(),
384        }
385    }
386
387    fn make_patch(id: &str, op: &str, parents: &[&str]) -> PatchProto {
388        PatchProto {
389            id: make_hash(id),
390            operation_type: op.to_string(),
391            touch_set: vec![format!("file_{id}")],
392            target_path: Some(format!("file_{id}")),
393            payload: String::new(),
394            parent_ids: parents.iter().map(|p| make_hash(p)).collect(),
395            author: "alice".to_string(),
396            message: format!("patch {id}"),
397            timestamp: 1000,
398        }
399    }
400
401    fn make_branch(name: &str, target: &str) -> BranchProto {
402        BranchProto {
403            name: name.to_string(),
404            target_id: make_hash(target),
405        }
406    }
407
408    #[test]
409    fn test_handshake_roundtrip() {
410        let req = HandshakeRequest {
411            client_version: 1,
412            client_name: "test".to_string(),
413        };
414        let rt: HandshakeRequest = roundtrip(&req);
415        assert_eq!(rt.client_version, 1);
416        assert_eq!(rt.client_name, "test");
417
418        let resp = HandshakeResponse {
419            server_version: 1,
420            server_name: "hub".to_string(),
421            compatible: true,
422        };
423        let rt: HandshakeResponse = roundtrip(&resp);
424        assert!(rt.compatible);
425    }
426
427    #[test]
428    fn test_auth_method_roundtrip() {
429        let methods = vec![
430            AuthMethod::None,
431            AuthMethod::Signature {
432                public_key: "pk".to_string(),
433                signature: "sig".to_string(),
434            },
435            AuthMethod::Token("tok".to_string()),
436        ];
437        for m in &methods {
438            let rt: AuthMethod = roundtrip(m);
439            match (m, &rt) {
440                (AuthMethod::None, AuthMethod::None) => {}
441                (
442                    AuthMethod::Signature {
443                        public_key: a,
444                        signature: b,
445                    },
446                    AuthMethod::Signature {
447                        public_key: c,
448                        signature: d,
449                    },
450                ) => {
451                    assert_eq!(a, c);
452                    assert_eq!(b, d);
453                }
454                (AuthMethod::Token(a), AuthMethod::Token(b)) => assert_eq!(a, b),
455                _ => panic!("auth method mismatch"),
456            }
457        }
458    }
459
460    #[test]
461    fn test_patch_proto_roundtrip() {
462        let p = make_patch("a".repeat(64).as_str(), "Create", &[]);
463        let rt: PatchProto = roundtrip(&p);
464        assert_eq!(rt.operation_type, "Create");
465        assert_eq!(rt.touch_set.len(), 1);
466        assert!(rt.target_path.is_some());
467        assert!(rt.parent_ids.is_empty());
468        assert_eq!(rt.author, "alice");
469    }
470
471    #[test]
472    fn test_patch_proto_with_parents() {
473        let parent = "b".repeat(64);
474        let p = make_patch("a".repeat(64).as_str(), "Modify", &[&parent]);
475        let rt: PatchProto = roundtrip(&p);
476        assert_eq!(rt.parent_ids.len(), 1);
477        assert_eq!(hash_to_hex(&rt.parent_ids[0]), parent);
478    }
479
480    #[test]
481    fn test_push_request_roundtrip() {
482        let req = PushRequest {
483            repo_id: "my-repo".to_string(),
484            patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
485            branches: vec![make_branch("main", "a".repeat(64).as_str())],
486            blobs: vec![BlobRef {
487                hash: make_hash("deadbeef"),
488                data: "aGVsbG8=".to_string(),
489                truncated: false,
490            }],
491            signature: Some(vec![1u8; 64]),
492            known_branches: Some(vec![make_branch("main", "prev".repeat(32).as_str())]),
493            force: true,
494        };
495        let rt: PushRequest = roundtrip(&req);
496        assert_eq!(rt.repo_id, "my-repo");
497        assert_eq!(rt.patches.len(), 1);
498        assert_eq!(rt.branches.len(), 1);
499        assert_eq!(rt.blobs.len(), 1);
500        assert!(rt.signature.is_some());
501        assert!(rt.known_branches.is_some());
502        assert!(rt.force);
503    }
504
505    #[test]
506    fn test_push_request_defaults() {
507        let req = PushRequest {
508            repo_id: "r".to_string(),
509            patches: vec![],
510            branches: vec![],
511            blobs: vec![],
512            signature: None,
513            known_branches: None,
514            force: false,
515        };
516        let json = serde_json::to_string(&req).unwrap();
517        let rt: PushRequest = serde_json::from_str(&json).unwrap();
518        assert!(rt.signature.is_none());
519        assert!(rt.known_branches.is_none());
520        assert!(!rt.force);
521    }
522
523    #[test]
524    fn test_pull_request_roundtrip() {
525        let req = PullRequest {
526            repo_id: "r".to_string(),
527            known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
528            max_depth: Some(10),
529        };
530        let rt: PullRequest = roundtrip(&req);
531        assert_eq!(rt.max_depth, Some(10));
532
533        let req2 = PullRequest {
534            repo_id: "r".to_string(),
535            known_branches: vec![],
536            max_depth: None,
537        };
538        let rt2: PullRequest = roundtrip(&req2);
539        assert!(rt2.max_depth.is_none());
540    }
541
542    #[test]
543    fn test_pull_response_roundtrip() {
544        let resp = PullResponse {
545            success: true,
546            error: None,
547            patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
548            branches: vec![make_branch("main", "a".repeat(64).as_str())],
549            blobs: vec![BlobRef {
550                hash: make_hash("abc"),
551                data: "dGVzdA==".to_string(),
552                truncated: false,
553            }],
554        };
555
556        let rt: PullResponse = roundtrip(&resp);
557        assert!(rt.success);
558        assert_eq!(rt.patches.len(), 1);
559        assert_eq!(rt.blobs.len(), 1);
560    }
561
562    #[test]
563    fn test_pull_response_error() {
564        let resp = PullResponse {
565            success: false,
566            error: Some("not found".to_string()),
567            patches: vec![],
568            branches: vec![],
569            blobs: vec![],
570        };
571        let rt: PullResponse = roundtrip(&resp);
572        assert!(!rt.success);
573        assert_eq!(rt.error, Some("not found".to_string()));
574    }
575
576    #[test]
577    fn test_blob_ref_roundtrip() {
578        let blob = BlobRef {
579            hash: make_hash("cafebabe"),
580            data: "SGVsbG8gV29ybGQ=".to_string(),
581            truncated: false,
582        };
583        let rt: BlobRef = roundtrip(&blob);
584        assert_eq!(rt.data, "SGVsbG8gV29ybGQ=");
585    }
586
587    #[test]
588    fn test_hash_helpers() {
589        let h = hex_to_hash("abcdef1234");
590        assert_eq!(hash_to_hex(&h), "abcdef1234");
591    }
592
593    #[test]
594    fn test_canonical_push_bytes_deterministic() {
595        let req = PushRequest {
596            repo_id: "test".to_string(),
597            patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
598            branches: vec![make_branch("main", "a".repeat(64).as_str())],
599            blobs: vec![],
600            signature: None,
601            known_branches: None,
602            force: false,
603        };
604        let b1 = canonical_push_bytes(&req);
605        let b2 = canonical_push_bytes(&req);
606        assert_eq!(b1, b2);
607    }
608
609    #[test]
610    fn test_canonical_push_bytes_different_repos() {
611        let make_req = |repo: &str| PushRequest {
612            repo_id: repo.to_string(),
613            patches: vec![],
614            branches: vec![],
615            blobs: vec![],
616            signature: None,
617            known_branches: None,
618            force: false,
619        };
620        let b1 = canonical_push_bytes(&make_req("repo-a"));
621        let b2 = canonical_push_bytes(&make_req("repo-b"));
622        assert_ne!(b1, b2);
623    }
624
625    #[test]
626    fn test_repo_info_response_roundtrip() {
627        let resp = RepoInfoResponse {
628            repo_id: "my-repo".to_string(),
629            patch_count: 42,
630            branches: vec![make_branch("main", "a".repeat(32).as_str())],
631            success: true,
632            error: None,
633        };
634        let rt: RepoInfoResponse = roundtrip(&resp);
635        assert_eq!(rt.patch_count, 42);
636        assert!(rt.success);
637
638        let err = RepoInfoResponse {
639            repo_id: "x".to_string(),
640            patch_count: 0,
641            branches: vec![],
642            success: false,
643            error: Some("not found".to_string()),
644        };
645        let rt2: RepoInfoResponse = roundtrip(&err);
646        assert!(!rt2.success);
647        assert_eq!(rt2.error, Some("not found".to_string()));
648    }
649
650    #[test]
651    fn test_list_repos_response_roundtrip() {
652        let resp = ListReposResponse {
653            repo_ids: vec!["a".to_string(), "b".to_string()],
654        };
655        let rt: ListReposResponse = roundtrip(&resp);
656        assert_eq!(rt.repo_ids, vec!["a", "b"]);
657    }
658
659    #[test]
660    fn test_push_response_roundtrip() {
661        let resp = PushResponse {
662            success: true,
663            error: None,
664            existing_patches: vec![make_hash("abc"), make_hash("def")],
665        };
666        let rt: PushResponse = roundtrip(&resp);
667        assert_eq!(rt.existing_patches.len(), 2);
668    }
669
670    #[test]
671    fn test_delta_roundtrip() {
672        let base = b"Hello, World!";
673        let target = b"Hello, Rust!";
674        let (_base_copy, delta) = compute_delta(base, target);
675        let result = apply_delta(base, &delta);
676        assert_eq!(result, target);
677    }
678
679    #[test]
680    fn test_delta_no_change() {
681        let base = b"identical data here";
682        let target = b"identical data here";
683        let (_base_copy, delta) = compute_delta(base, target);
684        assert!(delta.len() < target.len() + 25);
685        let result = apply_delta(base, &delta);
686        assert_eq!(result, target);
687    }
688
689    #[test]
690    fn test_delta_completely_different() {
691        let base = b"AAAA";
692        let target = b"BBBB";
693        let (_base_copy, delta) = compute_delta(base, target);
694        let result = apply_delta(base, &delta);
695        assert_eq!(result, target);
696    }
697
698    #[test]
699    fn test_pull_request_v2_roundtrip() {
700        let req = PullRequestV2 {
701            repo_id: "my-repo".to_string(),
702            known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
703            max_depth: Some(10),
704            known_blob_hashes: vec![make_hash("deadbeef")],
705            capabilities: ClientCapabilities {
706                supports_delta: true,
707                supports_compression: true,
708                max_blob_size: 1024 * 1024,
709            },
710        };
711        let rt: PullRequestV2 = roundtrip(&req);
712        assert_eq!(rt.repo_id, "my-repo");
713        assert_eq!(rt.max_depth, Some(10));
714        assert!(rt.capabilities.supports_delta);
715        assert_eq!(rt.known_blob_hashes.len(), 1);
716    }
717
718    #[test]
719    fn test_handshake_v2_roundtrip() {
720        let req = HandshakeRequestV2 {
721            client_version: 2,
722            client_name: "suture-cli".to_string(),
723            capabilities: ClientCapabilities {
724                supports_delta: true,
725                supports_compression: false,
726                max_blob_size: 512 * 1024,
727            },
728        };
729        let rt: HandshakeRequestV2 = roundtrip(&req);
730        assert_eq!(rt.client_version, 2);
731        assert!(rt.capabilities.supports_delta);
732        assert!(!rt.capabilities.supports_compression);
733
734        let resp = HandshakeResponseV2 {
735            server_version: 2,
736            server_name: "suture-hub".to_string(),
737            compatible: true,
738            server_capabilities: ServerCapabilities {
739                supports_delta: true,
740                supports_compression: true,
741                max_blob_size: 10 * 1024 * 1024,
742                protocol_versions: vec![1, 2],
743            },
744        };
745        let rt: HandshakeResponseV2 = roundtrip(&resp);
746        assert!(rt.compatible);
747        assert_eq!(rt.server_capabilities.protocol_versions, vec![1, 2]);
748    }
749
750    #[test]
751    fn test_client_capabilities_roundtrip() {
752        let caps = ClientCapabilities {
753            supports_delta: false,
754            supports_compression: true,
755            max_blob_size: 999,
756        };
757        let rt: ClientCapabilities = roundtrip(&caps);
758        assert!(!rt.supports_delta);
759        assert!(rt.supports_compression);
760        assert_eq!(rt.max_blob_size, 999);
761    }
762
763    #[test]
764    fn test_blob_delta_roundtrip() {
765        let delta = BlobDelta {
766            base_hash: make_hash("aaa"),
767            target_hash: make_hash("bbb"),
768            encoding: DeltaEncoding::BinaryPatch,
769            delta_data: "ZGF0YQ==".to_string(),
770        };
771        let rt: BlobDelta = roundtrip(&delta);
772        assert_eq!(hash_to_hex(&rt.base_hash), "aaa");
773        assert_eq!(hash_to_hex(&rt.target_hash), "bbb");
774        assert!(matches!(rt.encoding, DeltaEncoding::BinaryPatch));
775        assert_eq!(rt.delta_data, "ZGF0YQ==");
776
777        let full = BlobDelta {
778            base_hash: make_hash("aaa"),
779            target_hash: make_hash("bbb"),
780            encoding: DeltaEncoding::FullBlob,
781            delta_data: "Ynl0ZXM=".to_string(),
782        };
783        let rt: BlobDelta = roundtrip(&full);
784        assert!(matches!(rt.encoding, DeltaEncoding::FullBlob));
785    }
786
787    fn assert_delta_roundtrip(base: &[u8], target: &[u8]) {
788        let (_base_copy, delta) = compute_delta(base, target);
789        let result = apply_delta(base, &delta);
790        assert_eq!(
791            result,
792            target,
793            "delta roundtrip failed: base_len={}, target_len={}",
794            base.len(),
795            target.len()
796        );
797    }
798
799    #[test]
800    fn test_delta_small_1_to_10_bytes() {
801        for len in 1..=10u32 {
802            let base: Vec<u8> = (0..len).map(|i| (i * 7) as u8).collect();
803            let target: Vec<u8> = (0..len).map(|i| (i * 13 + 3) as u8).collect();
804            assert_delta_roundtrip(&base, &target);
805        }
806    }
807
808    #[test]
809    fn test_delta_medium_100_to_1000_bytes() {
810        for len in [100, 200, 500, 1000] {
811            let base: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
812            let mut target = base.clone();
813            for i in len / 3..len * 2 / 3 {
814                target[i] = target[i].wrapping_add(1);
815            }
816            assert_delta_roundtrip(&base, &target);
817        }
818    }
819
820    #[test]
821    fn test_delta_large_10kb_plus() {
822        for len in [10_240, 50_000, 100_000] {
823            let base: Vec<u8> = (0..len).map(|i| ((i * 7 + 13) % 251) as u8).collect();
824            let mut target = base.clone();
825            target[len / 2] = 0xFF;
826            assert_delta_roundtrip(&base, &target);
827        }
828    }
829
830    #[test]
831    fn test_delta_empty_base() {
832        assert_delta_roundtrip(b"", b"some target data that is reasonably long enough");
833    }
834
835    #[test]
836    fn test_delta_empty_target() {
837        assert_delta_roundtrip(b"some base data that is reasonably long enough too", b"");
838    }
839
840    #[test]
841    fn test_delta_both_empty() {
842        assert_delta_roundtrip(b"", b"");
843    }
844
845    #[test]
846    fn test_delta_identical() {
847        let data = b"The quick brown fox jumps over the lazy dog";
848        assert_delta_roundtrip(data, data);
849    }
850
851    #[test]
852    fn test_delta_base_is_prefix_of_target() {
853        assert_delta_roundtrip(
854            b"shared prefix data",
855            b"shared prefix data and extra suffix content here",
856        );
857    }
858
859    #[test]
860    fn test_delta_target_is_prefix_of_base() {
861        assert_delta_roundtrip(
862            b"shared prefix data and extra suffix content here",
863            b"shared prefix data",
864        );
865    }
866
867    #[test]
868    fn test_delta_completely_different_same_length() {
869        let base: Vec<u8> = (0..100).map(|i| (i * 3) as u8).collect();
870        let target: Vec<u8> = (0..100).map(|i| (i * 7 + 100) as u8).collect();
871        assert_delta_roundtrip(&base, &target);
872    }
873
874    #[test]
875    fn test_delta_completely_different_different_lengths() {
876        assert_delta_roundtrip(&vec![0xAA; 50], &vec![0xBB; 200]);
877    }
878
879    #[test]
880    fn test_delta_common_middle_section() {
881        let middle = b"COMMON_MIDDLE_SECTION_THAT_IS_LONG_ENOUGH";
882        let mut base = Vec::new();
883        base.extend_from_slice(b"DIFFERENT_START_XXXXXX_");
884        base.extend_from_slice(middle);
885        base.extend_from_slice(b"_DIFFERENT_END_XXXXXX");
886
887        let mut target = Vec::new();
888        target.extend_from_slice(b"CHANGED_PREFIX_");
889        target.extend_from_slice(middle);
890        target.extend_from_slice(b"_CHANGED_SUFFIX_DATA");
891
892        assert_delta_roundtrip(&base, &target);
893    }
894
895    #[test]
896    fn test_delta_single_byte_change() {
897        let base = vec![0u8; 1000];
898        let mut target = base.clone();
899        target[500] = 1;
900        assert_delta_roundtrip(&base, &target);
901    }
902
903    #[test]
904    fn test_delta_single_byte_base_and_target() {
905        assert_delta_roundtrip(b"A", b"B");
906    }
907
908    #[test]
909    fn test_delta_single_byte_identical() {
910        assert_delta_roundtrip(b"X", b"X");
911    }
912
913    #[test]
914    fn test_delta_prefix_overlap_large() {
915        let prefix: Vec<u8> = (0..60u8).collect();
916        let mut base = prefix.clone();
917        base.extend_from_slice(&vec![0x00; 60]);
918        let mut target = prefix.clone();
919        target.extend_from_slice(&(60..120u8).collect::<Vec<_>>());
920        assert_delta_roundtrip(&base, &target);
921    }
922
923    #[test]
924    fn test_delta_suffix_overlap_large() {
925        let suffix: Vec<u8> = (60..120u8).collect();
926        let mut base = vec![0x00; 60];
927        base.extend_from_slice(&suffix);
928        let mut target = (0..60u8).collect::<Vec<_>>();
929        target.extend_from_slice(&suffix);
930        assert_delta_roundtrip(&base, &target);
931    }
932
933    #[test]
934    fn test_compress_decompress_empty() {
935        let compressed = compress(b"").unwrap();
936        assert_eq!(decompress(&compressed).unwrap(), b"");
937    }
938
939    #[test]
940    fn test_compress_decompress_small_1_to_100() {
941        for len in 1..=100u32 {
942            let data: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
943            let compressed = compress(&data).unwrap();
944            assert_eq!(
945                decompress(&compressed).unwrap(),
946                data,
947                "failed for len={len}"
948            );
949        }
950    }
951
952    #[test]
953    fn test_compress_decompress_medium() {
954        for len in [100, 500, 1000, 5000, 10_000] {
955            let data: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
956            let compressed = compress(&data).unwrap();
957            assert_eq!(
958                decompress(&compressed).unwrap(),
959                data,
960                "failed for len={len}"
961            );
962        }
963    }
964
965    #[test]
966    fn test_compress_decompress_large() {
967        let len = 200_000usize;
968        let data: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
969        let compressed = compress(&data).unwrap();
970        assert!(
971            compressed.len() < data.len(),
972            "compressed should be smaller for repetitive data"
973        );
974        assert_eq!(decompress(&compressed).unwrap(), data);
975    }
976
977    #[test]
978    fn test_compress_decompress_incompressible() {
979        let mut data = Vec::with_capacity(100_000);
980        let mut hasher = std::collections::hash_map::DefaultHasher::new();
981        for i in 0..100_000 {
982            use std::hash::{Hash, Hasher};
983            i.hash(&mut hasher);
984            data.push((hasher.finish() % 256) as u8);
985            hasher = std::collections::hash_map::DefaultHasher::new();
986        }
987        let compressed = compress(&data).unwrap();
988        assert_eq!(decompress(&compressed).unwrap(), data);
989    }
990
991    #[test]
992    fn test_compress_decompress_highly_compressible() {
993        let data = vec![0xAAu8; 500_000];
994        let compressed = compress(&data).unwrap();
995        assert!(
996            compressed.len() < 100,
997            "highly compressible data should be tiny"
998        );
999        assert_eq!(decompress(&compressed).unwrap(), data);
1000    }
1001
1002    #[test]
1003    fn test_decompress_invalid_data_fails() {
1004        assert!(decompress(b"not valid zstd data").is_err());
1005    }
1006
1007    #[test]
1008    fn test_decompress_empty_input_fails() {
1009        assert!(decompress(b"").is_err());
1010    }
1011
1012    #[test]
1013    fn test_server_capabilities_roundtrip() {
1014        let caps = ServerCapabilities {
1015            supports_delta: true,
1016            supports_compression: true,
1017            max_blob_size: 50 * 1024 * 1024,
1018            protocol_versions: vec![1, 2],
1019        };
1020        let rt: ServerCapabilities = roundtrip(&caps);
1021        assert!(rt.supports_delta);
1022        assert!(rt.supports_compression);
1023        assert_eq!(rt.max_blob_size, 50 * 1024 * 1024);
1024        assert_eq!(rt.protocol_versions, vec![1, 2]);
1025    }
1026
1027    #[test]
1028    fn test_capability_version_matching() {
1029        let server_caps = ServerCapabilities {
1030            supports_delta: true,
1031            supports_compression: false,
1032            max_blob_size: 1024 * 1024,
1033            protocol_versions: vec![1, 2],
1034        };
1035        let client_caps = ClientCapabilities {
1036            supports_delta: true,
1037            supports_compression: true,
1038            max_blob_size: 1024 * 1024,
1039        };
1040        assert!(server_caps.protocol_versions.contains(&PROTOCOL_VERSION));
1041        assert!(server_caps.supports_delta && client_caps.supports_delta);
1042        assert!(!(server_caps.supports_compression && client_caps.supports_compression));
1043        assert!(client_caps.max_blob_size <= server_caps.max_blob_size);
1044    }
1045
1046    #[test]
1047    fn test_capability_version_mismatch() {
1048        let server_caps = ServerCapabilities {
1049            supports_delta: false,
1050            supports_compression: false,
1051            max_blob_size: 1024,
1052            protocol_versions: vec![1],
1053        };
1054        let client_caps = ClientCapabilities {
1055            supports_delta: true,
1056            supports_compression: true,
1057            max_blob_size: 10 * 1024 * 1024,
1058        };
1059        assert!(!server_caps.protocol_versions.contains(&PROTOCOL_VERSION_V2));
1060        assert!(!server_caps.supports_delta || !client_caps.supports_delta);
1061        assert!(client_caps.max_blob_size > server_caps.max_blob_size);
1062    }
1063
1064    #[test]
1065    fn test_push_request_v2_roundtrip() {
1066        let req = PushRequestV2 {
1067            repo_id: "my-repo".to_string(),
1068            patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
1069            branches: vec![make_branch("main", "a".repeat(64).as_str())],
1070            blobs: vec![BlobRef {
1071                hash: make_hash("abc"),
1072                data: "dGVzdA==".to_string(),
1073                truncated: false,
1074            }],
1075            deltas: vec![BlobDelta {
1076                base_hash: make_hash("base"),
1077                target_hash: make_hash("target"),
1078                encoding: DeltaEncoding::BinaryPatch,
1079                delta_data: "ZGVsdGE=".to_string(),
1080            }],
1081            signature: None,
1082            known_branches: None,
1083            force: false,
1084        };
1085        let rt: PushRequestV2 = roundtrip(&req);
1086        assert_eq!(rt.repo_id, "my-repo");
1087        assert_eq!(rt.deltas.len(), 1);
1088        assert_eq!(rt.patches.len(), 1);
1089    }
1090
1091    #[test]
1092    fn test_pull_response_v2_roundtrip() {
1093        let resp = PullResponseV2 {
1094            success: true,
1095            error: None,
1096            patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
1097            branches: vec![make_branch("main", "a".repeat(64).as_str())],
1098            blobs: vec![BlobRef {
1099                hash: make_hash("abc"),
1100                data: "dGVzdA==".to_string(),
1101                truncated: false,
1102            }],
1103            deltas: vec![BlobDelta {
1104                base_hash: make_hash("old"),
1105                target_hash: make_hash("new"),
1106                encoding: DeltaEncoding::FullBlob,
1107                delta_data: "ZnVsbA==".to_string(),
1108            }],
1109            protocol_version: 2,
1110        };
1111        let rt: PullResponseV2 = roundtrip(&resp);
1112        assert!(rt.success);
1113        assert_eq!(rt.protocol_version, 2);
1114        assert_eq!(rt.deltas.len(), 1);
1115        assert_eq!(rt.blobs.len(), 1);
1116    }
1117
1118    #[test]
1119    fn test_protocol_versions() {
1120        assert_eq!(PROTOCOL_VERSION, 1);
1121        assert_eq!(PROTOCOL_VERSION_V2, 2);
1122        assert_ne!(PROTOCOL_VERSION, PROTOCOL_VERSION_V2);
1123    }
1124
1125    #[test]
1126    fn test_auth_request_roundtrip() {
1127        let req = AuthRequest {
1128            method: AuthMethod::Token("secret".to_string()),
1129            timestamp: 12345,
1130        };
1131        let rt: AuthRequest = roundtrip(&req);
1132        assert_eq!(rt.timestamp, 12345);
1133        match rt.method {
1134            AuthMethod::Token(t) => assert_eq!(t, "secret"),
1135            _ => panic!("expected Token auth method"),
1136        }
1137    }
1138}