1use serde::{Deserialize, Serialize};
8
9pub const PROTOCOL_VERSION: u32 = 1;
10pub const PROTOCOL_VERSION_V2: u32 = 2;
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct HandshakeRequest {
14 pub client_version: u32,
15 pub client_name: String,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct HandshakeResponse {
20 pub server_version: u32,
21 pub server_name: String,
22 pub compatible: bool,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub enum AuthMethod {
27 None,
28 Signature {
29 public_key: String,
30 signature: String,
31 },
32 Token(String),
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct AuthRequest {
37 pub method: AuthMethod,
38 pub timestamp: u64,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct HashProto {
43 pub value: String,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
47pub struct PatchProto {
48 pub id: HashProto,
49 pub operation_type: String,
50 pub touch_set: Vec<String>,
51 pub target_path: Option<String>,
52 pub payload: String,
53 pub parent_ids: Vec<HashProto>,
54 pub author: String,
55 pub message: String,
56 pub timestamp: u64,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub struct BranchProto {
61 pub name: String,
62 pub target_id: HashProto,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct BlobRef {
67 pub hash: HashProto,
68 pub data: String,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72pub struct PushRequest {
73 pub repo_id: String,
74 pub patches: Vec<PatchProto>,
75 pub branches: Vec<BranchProto>,
76 pub blobs: Vec<BlobRef>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub signature: Option<Vec<u8>>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub known_branches: Option<Vec<BranchProto>>,
86 #[serde(default)]
88 pub force: bool,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92pub struct PushResponse {
93 pub success: bool,
94 pub error: Option<String>,
95 pub existing_patches: Vec<HashProto>,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
99pub struct PullRequest {
100 pub repo_id: String,
101 pub known_branches: Vec<BranchProto>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub max_depth: Option<u32>,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109pub struct PullResponse {
110 pub success: bool,
111 pub error: Option<String>,
112 pub patches: Vec<PatchProto>,
113 pub branches: Vec<BranchProto>,
114 pub blobs: Vec<BlobRef>,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118pub struct ListReposResponse {
119 pub repo_ids: Vec<String>,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123pub struct RepoInfoResponse {
124 pub repo_id: String,
125 pub patch_count: u64,
126 pub branches: Vec<BranchProto>,
127 pub success: bool,
128 pub error: Option<String>,
129}
130
131pub fn hash_to_hex(h: &HashProto) -> String {
132 h.value.clone()
133}
134
135pub fn compress(data: &[u8]) -> Result<Vec<u8>, String> {
136 zstd::encode_all(data, 3).map_err(|e| format!("zstd compression failed: {e}"))
137}
138
139pub fn decompress(data: &[u8]) -> Result<Vec<u8>, String> {
140 zstd::decode_all(data).map_err(|e| format!("zstd decompression failed: {e}"))
141}
142
143pub fn hex_to_hash(hex: &str) -> HashProto {
144 HashProto {
145 value: hex.to_string(),
146 }
147}
148
149pub fn canonical_push_bytes(req: &PushRequest) -> Vec<u8> {
152 let mut buf = Vec::new();
153
154 buf.extend_from_slice(req.repo_id.as_bytes());
155 buf.push(0);
156
157 buf.extend_from_slice(&(req.patches.len() as u64).to_le_bytes());
158 for patch in &req.patches {
159 buf.extend_from_slice(patch.id.value.as_bytes());
160 buf.push(0);
161 buf.extend_from_slice(patch.operation_type.as_bytes());
162 buf.push(0);
163 buf.extend_from_slice(patch.author.as_bytes());
164 buf.push(0);
165 buf.extend_from_slice(patch.message.as_bytes());
166 buf.push(0);
167 buf.extend_from_slice(&patch.timestamp.to_le_bytes());
168 buf.push(0);
169 }
170
171 buf.extend_from_slice(&(req.branches.len() as u64).to_le_bytes());
172 for branch in &req.branches {
173 buf.extend_from_slice(branch.name.as_bytes());
174 buf.push(0);
175 buf.extend_from_slice(branch.target_id.value.as_bytes());
176 buf.push(0);
177 }
178
179 buf
180}
181
182#[derive(Clone, Debug, Serialize, Deserialize)]
183pub enum DeltaEncoding {
184 BinaryPatch,
185 FullBlob,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
189pub struct BlobDelta {
190 pub base_hash: HashProto,
191 pub target_hash: HashProto,
192 pub encoding: DeltaEncoding,
193 pub delta_data: String,
194}
195
196#[derive(Clone, Debug, Serialize, Deserialize)]
197pub struct ClientCapabilities {
198 pub supports_delta: bool,
199 pub supports_compression: bool,
200 pub max_blob_size: u64,
201}
202
203#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct ServerCapabilities {
205 pub supports_delta: bool,
206 pub supports_compression: bool,
207 pub max_blob_size: u64,
208 pub protocol_versions: Vec<u32>,
209}
210
211#[derive(Debug, Serialize, Deserialize)]
212pub struct PullRequestV2 {
213 pub repo_id: String,
214 pub known_branches: Vec<BranchProto>,
215 pub max_depth: Option<u32>,
216 pub known_blob_hashes: Vec<HashProto>,
217 pub capabilities: ClientCapabilities,
218}
219
220#[derive(Debug, Serialize, Deserialize)]
221pub struct PullResponseV2 {
222 pub success: bool,
223 pub error: Option<String>,
224 pub patches: Vec<PatchProto>,
225 pub branches: Vec<BranchProto>,
226 pub blobs: Vec<BlobRef>,
227 pub deltas: Vec<BlobDelta>,
228 pub protocol_version: u32,
229}
230
231#[derive(Debug, Serialize, Deserialize)]
232pub struct PushRequestV2 {
233 pub repo_id: String,
234 pub patches: Vec<PatchProto>,
235 pub branches: Vec<BranchProto>,
236 pub blobs: Vec<BlobRef>,
237 pub deltas: Vec<BlobDelta>,
238 pub signature: Option<Vec<u8>>,
239 pub known_branches: Option<Vec<BranchProto>>,
240 pub force: bool,
241}
242
243#[derive(Clone, Debug, Serialize, Deserialize)]
244pub struct HandshakeRequestV2 {
245 pub client_version: u32,
246 pub client_name: String,
247 pub capabilities: ClientCapabilities,
248}
249
250#[derive(Clone, Debug, Serialize, Deserialize)]
251pub struct HandshakeResponseV2 {
252 pub server_version: u32,
253 pub server_name: String,
254 pub compatible: bool,
255 pub server_capabilities: ServerCapabilities,
256}
257
258pub fn compute_delta(base: &[u8], target: &[u8]) -> (Vec<u8>, Vec<u8>) {
259 let prefix_len = base
260 .iter()
261 .zip(target.iter())
262 .take_while(|(a, b)| a == b)
263 .count();
264
265 let max_suffix_base = base.len().saturating_sub(prefix_len);
266 let max_suffix_target = target.len().saturating_sub(prefix_len);
267 let suffix_len = base[prefix_len..]
268 .iter()
269 .rev()
270 .zip(target[prefix_len..].iter().rev())
271 .take_while(|(a, b)| a == b)
272 .count()
273 .min(max_suffix_base)
274 .min(max_suffix_target);
275
276 let changed_start = prefix_len;
277 let changed_end_target = target.len().saturating_sub(suffix_len);
278 let changed = &target[changed_start..changed_end_target];
279
280 if changed.len() < target.len() {
281 let mut delta = Vec::new();
282 delta.extend_from_slice(&(prefix_len as u64).to_le_bytes());
283 delta.extend_from_slice(&(suffix_len as u64).to_le_bytes());
284 delta.extend_from_slice(&(target.len() as u64).to_le_bytes());
285 delta.extend_from_slice(changed);
286 (base.to_vec(), delta)
287 } else {
288 (base.to_vec(), target.to_vec())
289 }
290}
291
292pub fn apply_delta(base: &[u8], delta: &[u8]) -> Vec<u8> {
293 if delta.len() < 24 {
294 return delta.to_vec();
295 }
296 let prefix_len = u64::from_le_bytes(delta[0..8].try_into().unwrap_or([0; 8])) as usize;
297 let suffix_len = u64::from_le_bytes(delta[8..16].try_into().unwrap_or([0; 8])) as usize;
298 let total_len = u64::from_le_bytes(delta[16..24].try_into().unwrap_or([0; 8])) as usize;
299 let changed = &delta[24..];
300
301 let mut result = Vec::with_capacity(total_len);
302 result.extend_from_slice(&base[..prefix_len.min(base.len())]);
303 result.extend_from_slice(changed);
304 result.extend_from_slice(&base[base.len().saturating_sub(suffix_len)..]);
305 result
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 fn roundtrip<T: Serialize + for<'de> Deserialize<'de>>(val: &T) -> T {
313 let json = serde_json::to_string(val).expect("serialize");
314 serde_json::from_str(&json).expect("deserialize")
315 }
316
317 fn make_hash(hex: &str) -> HashProto {
318 HashProto {
319 value: hex.to_string(),
320 }
321 }
322
323 fn make_patch(id: &str, op: &str, parents: &[&str]) -> PatchProto {
324 PatchProto {
325 id: make_hash(id),
326 operation_type: op.to_string(),
327 touch_set: vec![format!("file_{id}")],
328 target_path: Some(format!("file_{id}")),
329 payload: String::new(),
330 parent_ids: parents.iter().map(|p| make_hash(p)).collect(),
331 author: "alice".to_string(),
332 message: format!("patch {id}"),
333 timestamp: 1000,
334 }
335 }
336
337 fn make_branch(name: &str, target: &str) -> BranchProto {
338 BranchProto {
339 name: name.to_string(),
340 target_id: make_hash(target),
341 }
342 }
343
344 #[test]
345 fn test_handshake_roundtrip() {
346 let req = HandshakeRequest {
347 client_version: 1,
348 client_name: "test".to_string(),
349 };
350 let rt: HandshakeRequest = roundtrip(&req);
351 assert_eq!(rt.client_version, 1);
352 assert_eq!(rt.client_name, "test");
353
354 let resp = HandshakeResponse {
355 server_version: 1,
356 server_name: "hub".to_string(),
357 compatible: true,
358 };
359 let rt: HandshakeResponse = roundtrip(&resp);
360 assert!(rt.compatible);
361 }
362
363 #[test]
364 fn test_auth_method_roundtrip() {
365 let methods = vec![
366 AuthMethod::None,
367 AuthMethod::Signature {
368 public_key: "pk".to_string(),
369 signature: "sig".to_string(),
370 },
371 AuthMethod::Token("tok".to_string()),
372 ];
373 for m in &methods {
374 let rt: AuthMethod = roundtrip(m);
375 match (m, &rt) {
376 (AuthMethod::None, AuthMethod::None) => {}
377 (
378 AuthMethod::Signature {
379 public_key: a,
380 signature: b,
381 },
382 AuthMethod::Signature {
383 public_key: c,
384 signature: d,
385 },
386 ) => {
387 assert_eq!(a, c);
388 assert_eq!(b, d);
389 }
390 (AuthMethod::Token(a), AuthMethod::Token(b)) => assert_eq!(a, b),
391 _ => panic!("auth method mismatch"),
392 }
393 }
394 }
395
396 #[test]
397 fn test_patch_proto_roundtrip() {
398 let p = make_patch("a".repeat(64).as_str(), "Create", &[]);
399 let rt: PatchProto = roundtrip(&p);
400 assert_eq!(rt.operation_type, "Create");
401 assert_eq!(rt.touch_set.len(), 1);
402 assert!(rt.target_path.is_some());
403 assert!(rt.parent_ids.is_empty());
404 assert_eq!(rt.author, "alice");
405 }
406
407 #[test]
408 fn test_patch_proto_with_parents() {
409 let parent = "b".repeat(64);
410 let p = make_patch("a".repeat(64).as_str(), "Modify", &[&parent]);
411 let rt: PatchProto = roundtrip(&p);
412 assert_eq!(rt.parent_ids.len(), 1);
413 assert_eq!(hash_to_hex(&rt.parent_ids[0]), parent);
414 }
415
416 #[test]
417 fn test_push_request_roundtrip() {
418 let req = PushRequest {
419 repo_id: "my-repo".to_string(),
420 patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
421 branches: vec![make_branch("main", "a".repeat(64).as_str())],
422 blobs: vec![BlobRef {
423 hash: make_hash("deadbeef"),
424 data: "aGVsbG8=".to_string(),
425 }],
426 signature: Some(vec![1u8; 64]),
427 known_branches: Some(vec![make_branch("main", "prev".repeat(32).as_str())]),
428 force: true,
429 };
430 let rt: PushRequest = roundtrip(&req);
431 assert_eq!(rt.repo_id, "my-repo");
432 assert_eq!(rt.patches.len(), 1);
433 assert_eq!(rt.branches.len(), 1);
434 assert_eq!(rt.blobs.len(), 1);
435 assert!(rt.signature.is_some());
436 assert!(rt.known_branches.is_some());
437 assert!(rt.force);
438 }
439
440 #[test]
441 fn test_push_request_defaults() {
442 let req = PushRequest {
443 repo_id: "r".to_string(),
444 patches: vec![],
445 branches: vec![],
446 blobs: vec![],
447 signature: None,
448 known_branches: None,
449 force: false,
450 };
451 let json = serde_json::to_string(&req).unwrap();
452 let rt: PushRequest = serde_json::from_str(&json).unwrap();
453 assert!(rt.signature.is_none());
454 assert!(rt.known_branches.is_none());
455 assert!(!rt.force);
456 }
457
458 #[test]
459 fn test_pull_request_roundtrip() {
460 let req = PullRequest {
461 repo_id: "r".to_string(),
462 known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
463 max_depth: Some(10),
464 };
465 let rt: PullRequest = roundtrip(&req);
466 assert_eq!(rt.max_depth, Some(10));
467
468 let req2 = PullRequest {
469 repo_id: "r".to_string(),
470 known_branches: vec![],
471 max_depth: None,
472 };
473 let rt2: PullRequest = roundtrip(&req2);
474 assert!(rt2.max_depth.is_none());
475 }
476
477 #[test]
478 fn test_pull_response_roundtrip() {
479 let resp = PullResponse {
480 success: true,
481 error: None,
482 patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
483 branches: vec![make_branch("main", "a".repeat(64).as_str())],
484 blobs: vec![BlobRef {
485 hash: make_hash("abc"),
486 data: "dGVzdA==".to_string(),
487 }],
488 };
489 let rt: PullResponse = roundtrip(&resp);
490 assert!(rt.success);
491 assert_eq!(rt.patches.len(), 1);
492 assert_eq!(rt.blobs.len(), 1);
493 }
494
495 #[test]
496 fn test_pull_response_error() {
497 let resp = PullResponse {
498 success: false,
499 error: Some("not found".to_string()),
500 patches: vec![],
501 branches: vec![],
502 blobs: vec![],
503 };
504 let rt: PullResponse = roundtrip(&resp);
505 assert!(!rt.success);
506 assert_eq!(rt.error, Some("not found".to_string()));
507 }
508
509 #[test]
510 fn test_blob_ref_roundtrip() {
511 let blob = BlobRef {
512 hash: make_hash("cafebabe"),
513 data: "SGVsbG8gV29ybGQ=".to_string(),
514 };
515 let rt: BlobRef = roundtrip(&blob);
516 assert_eq!(rt.data, "SGVsbG8gV29ybGQ=");
517 }
518
519 #[test]
520 fn test_hash_helpers() {
521 let h = hex_to_hash("abcdef1234");
522 assert_eq!(hash_to_hex(&h), "abcdef1234");
523 }
524
525 #[test]
526 fn test_canonical_push_bytes_deterministic() {
527 let req = PushRequest {
528 repo_id: "test".to_string(),
529 patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
530 branches: vec![make_branch("main", "a".repeat(64).as_str())],
531 blobs: vec![],
532 signature: None,
533 known_branches: None,
534 force: false,
535 };
536 let b1 = canonical_push_bytes(&req);
537 let b2 = canonical_push_bytes(&req);
538 assert_eq!(b1, b2);
539 }
540
541 #[test]
542 fn test_canonical_push_bytes_different_repos() {
543 let make_req = |repo: &str| PushRequest {
544 repo_id: repo.to_string(),
545 patches: vec![],
546 branches: vec![],
547 blobs: vec![],
548 signature: None,
549 known_branches: None,
550 force: false,
551 };
552 let b1 = canonical_push_bytes(&make_req("repo-a"));
553 let b2 = canonical_push_bytes(&make_req("repo-b"));
554 assert_ne!(b1, b2);
555 }
556
557 #[test]
558 fn test_repo_info_response_roundtrip() {
559 let resp = RepoInfoResponse {
560 repo_id: "my-repo".to_string(),
561 patch_count: 42,
562 branches: vec![make_branch("main", "a".repeat(32).as_str())],
563 success: true,
564 error: None,
565 };
566 let rt: RepoInfoResponse = roundtrip(&resp);
567 assert_eq!(rt.patch_count, 42);
568 assert!(rt.success);
569
570 let err = RepoInfoResponse {
571 repo_id: "x".to_string(),
572 patch_count: 0,
573 branches: vec![],
574 success: false,
575 error: Some("not found".to_string()),
576 };
577 let rt2: RepoInfoResponse = roundtrip(&err);
578 assert!(!rt2.success);
579 assert_eq!(rt2.error, Some("not found".to_string()));
580 }
581
582 #[test]
583 fn test_list_repos_response_roundtrip() {
584 let resp = ListReposResponse {
585 repo_ids: vec!["a".to_string(), "b".to_string()],
586 };
587 let rt: ListReposResponse = roundtrip(&resp);
588 assert_eq!(rt.repo_ids, vec!["a", "b"]);
589 }
590
591 #[test]
592 fn test_push_response_roundtrip() {
593 let resp = PushResponse {
594 success: true,
595 error: None,
596 existing_patches: vec![make_hash("abc"), make_hash("def")],
597 };
598 let rt: PushResponse = roundtrip(&resp);
599 assert_eq!(rt.existing_patches.len(), 2);
600 }
601
602 #[test]
603 fn test_delta_roundtrip() {
604 let base = b"Hello, World!";
605 let target = b"Hello, Rust!";
606 let (_base_copy, delta) = compute_delta(base, target);
607 let result = apply_delta(base, &delta);
608 assert_eq!(result, target);
609 }
610
611 #[test]
612 fn test_delta_no_change() {
613 let base = b"identical data here";
614 let target = b"identical data here";
615 let (_base_copy, delta) = compute_delta(base, target);
616 assert!(delta.len() < target.len() + 24);
617 let result = apply_delta(base, &delta);
618 assert_eq!(result, target);
619 }
620
621 #[test]
622 fn test_delta_completely_different() {
623 let base = b"AAAA";
624 let target = b"BBBB";
625 let (_base_copy, delta) = compute_delta(base, target);
626 let result = apply_delta(base, &delta);
627 assert_eq!(result, target);
628 }
629
630 #[test]
631 fn test_pull_request_v2_roundtrip() {
632 let req = PullRequestV2 {
633 repo_id: "my-repo".to_string(),
634 known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
635 max_depth: Some(10),
636 known_blob_hashes: vec![make_hash("deadbeef")],
637 capabilities: ClientCapabilities {
638 supports_delta: true,
639 supports_compression: true,
640 max_blob_size: 1024 * 1024,
641 },
642 };
643 let rt: PullRequestV2 = roundtrip(&req);
644 assert_eq!(rt.repo_id, "my-repo");
645 assert_eq!(rt.max_depth, Some(10));
646 assert!(rt.capabilities.supports_delta);
647 assert_eq!(rt.known_blob_hashes.len(), 1);
648 }
649
650 #[test]
651 fn test_handshake_v2_roundtrip() {
652 let req = HandshakeRequestV2 {
653 client_version: 2,
654 client_name: "suture-cli".to_string(),
655 capabilities: ClientCapabilities {
656 supports_delta: true,
657 supports_compression: false,
658 max_blob_size: 512 * 1024,
659 },
660 };
661 let rt: HandshakeRequestV2 = roundtrip(&req);
662 assert_eq!(rt.client_version, 2);
663 assert!(rt.capabilities.supports_delta);
664 assert!(!rt.capabilities.supports_compression);
665
666 let resp = HandshakeResponseV2 {
667 server_version: 2,
668 server_name: "suture-hub".to_string(),
669 compatible: true,
670 server_capabilities: ServerCapabilities {
671 supports_delta: true,
672 supports_compression: true,
673 max_blob_size: 10 * 1024 * 1024,
674 protocol_versions: vec![1, 2],
675 },
676 };
677 let rt: HandshakeResponseV2 = roundtrip(&resp);
678 assert!(rt.compatible);
679 assert_eq!(rt.server_capabilities.protocol_versions, vec![1, 2]);
680 }
681
682 #[test]
683 fn test_client_capabilities_roundtrip() {
684 let caps = ClientCapabilities {
685 supports_delta: false,
686 supports_compression: true,
687 max_blob_size: 999,
688 };
689 let rt: ClientCapabilities = roundtrip(&caps);
690 assert!(!rt.supports_delta);
691 assert!(rt.supports_compression);
692 assert_eq!(rt.max_blob_size, 999);
693 }
694
695 #[test]
696 fn test_blob_delta_roundtrip() {
697 let delta = BlobDelta {
698 base_hash: make_hash("aaa"),
699 target_hash: make_hash("bbb"),
700 encoding: DeltaEncoding::BinaryPatch,
701 delta_data: "ZGF0YQ==".to_string(),
702 };
703 let rt: BlobDelta = roundtrip(&delta);
704 assert_eq!(hash_to_hex(&rt.base_hash), "aaa");
705 assert_eq!(hash_to_hex(&rt.target_hash), "bbb");
706 assert!(matches!(rt.encoding, DeltaEncoding::BinaryPatch));
707 assert_eq!(rt.delta_data, "ZGF0YQ==");
708
709 let full = BlobDelta {
710 base_hash: make_hash("aaa"),
711 target_hash: make_hash("bbb"),
712 encoding: DeltaEncoding::FullBlob,
713 delta_data: "Ynl0ZXM=".to_string(),
714 };
715 let rt: BlobDelta = roundtrip(&full);
716 assert!(matches!(rt.encoding, DeltaEncoding::FullBlob));
717 }
718}