Skip to main content

suture_protocol/
lib.rs

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