use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
pub const PROTOCOL_VERSION_V2: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HandshakeRequest {
pub client_version: u32,
pub client_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HandshakeResponse {
pub server_version: u32,
pub server_name: String,
pub compatible: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum AuthMethod {
None,
Signature {
public_key: String,
signature: String,
},
Token(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthRequest {
pub method: AuthMethod,
pub timestamp: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HashProto {
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PatchProto {
pub id: HashProto,
pub operation_type: String,
pub touch_set: Vec<String>,
pub target_path: Option<String>,
pub payload: String,
pub parent_ids: Vec<HashProto>,
pub author: String,
pub message: String,
pub timestamp: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProto {
pub name: String,
pub target_id: HashProto,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlobRef {
pub hash: HashProto,
pub data: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PushRequest {
pub repo_id: String,
pub patches: Vec<PatchProto>,
pub branches: Vec<BranchProto>,
pub blobs: Vec<BlobRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub known_branches: Option<Vec<BranchProto>>,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PushResponse {
pub success: bool,
pub error: Option<String>,
pub existing_patches: Vec<HashProto>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullRequest {
pub repo_id: String,
pub known_branches: Vec<BranchProto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_depth: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullResponse {
pub success: bool,
pub error: Option<String>,
pub patches: Vec<PatchProto>,
pub branches: Vec<BranchProto>,
pub blobs: Vec<BlobRef>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListReposResponse {
pub repo_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepoInfoResponse {
pub repo_id: String,
pub patch_count: u64,
pub branches: Vec<BranchProto>,
pub success: bool,
pub error: Option<String>,
}
pub fn hash_to_hex(h: &HashProto) -> String {
h.value.clone()
}
pub fn compress(data: &[u8]) -> Result<Vec<u8>, String> {
zstd::encode_all(data, 3).map_err(|e| format!("zstd compression failed: {e}"))
}
pub fn decompress(data: &[u8]) -> Result<Vec<u8>, String> {
zstd::decode_all(data).map_err(|e| format!("zstd decompression failed: {e}"))
}
pub fn hex_to_hash(hex: &str) -> HashProto {
HashProto {
value: hex.to_string(),
}
}
pub fn canonical_push_bytes(req: &PushRequest) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(req.repo_id.as_bytes());
buf.push(0);
buf.extend_from_slice(&(req.patches.len() as u64).to_le_bytes());
for patch in &req.patches {
buf.extend_from_slice(patch.id.value.as_bytes());
buf.push(0);
buf.extend_from_slice(patch.operation_type.as_bytes());
buf.push(0);
buf.extend_from_slice(patch.author.as_bytes());
buf.push(0);
buf.extend_from_slice(patch.message.as_bytes());
buf.push(0);
buf.extend_from_slice(&patch.timestamp.to_le_bytes());
buf.push(0);
}
buf.extend_from_slice(&(req.branches.len() as u64).to_le_bytes());
for branch in &req.branches {
buf.extend_from_slice(branch.name.as_bytes());
buf.push(0);
buf.extend_from_slice(branch.target_id.value.as_bytes());
buf.push(0);
}
buf
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DeltaEncoding {
BinaryPatch,
FullBlob,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlobDelta {
pub base_hash: HashProto,
pub target_hash: HashProto,
pub encoding: DeltaEncoding,
pub delta_data: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientCapabilities {
pub supports_delta: bool,
pub supports_compression: bool,
pub max_blob_size: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerCapabilities {
pub supports_delta: bool,
pub supports_compression: bool,
pub max_blob_size: u64,
pub protocol_versions: Vec<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullRequestV2 {
pub repo_id: String,
pub known_branches: Vec<BranchProto>,
pub max_depth: Option<u32>,
pub known_blob_hashes: Vec<HashProto>,
pub capabilities: ClientCapabilities,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PullResponseV2 {
pub success: bool,
pub error: Option<String>,
pub patches: Vec<PatchProto>,
pub branches: Vec<BranchProto>,
pub blobs: Vec<BlobRef>,
pub deltas: Vec<BlobDelta>,
pub protocol_version: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PushRequestV2 {
pub repo_id: String,
pub patches: Vec<PatchProto>,
pub branches: Vec<BranchProto>,
pub blobs: Vec<BlobRef>,
pub deltas: Vec<BlobDelta>,
pub signature: Option<Vec<u8>>,
pub known_branches: Option<Vec<BranchProto>>,
pub force: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HandshakeRequestV2 {
pub client_version: u32,
pub client_name: String,
pub capabilities: ClientCapabilities,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HandshakeResponseV2 {
pub server_version: u32,
pub server_name: String,
pub compatible: bool,
pub server_capabilities: ServerCapabilities,
}
pub fn compute_delta(base: &[u8], target: &[u8]) -> (Vec<u8>, Vec<u8>) {
let prefix_len = base
.iter()
.zip(target.iter())
.take_while(|(a, b)| a == b)
.count();
let max_suffix_base = base.len().saturating_sub(prefix_len);
let max_suffix_target = target.len().saturating_sub(prefix_len);
let suffix_len = base[prefix_len..]
.iter()
.rev()
.zip(target[prefix_len..].iter().rev())
.take_while(|(a, b)| a == b)
.count()
.min(max_suffix_base)
.min(max_suffix_target);
let changed_start = prefix_len;
let changed_end_target = target.len().saturating_sub(suffix_len);
let changed = &target[changed_start..changed_end_target];
if changed.len() < target.len() {
let mut delta = Vec::new();
delta.extend_from_slice(&(prefix_len as u64).to_le_bytes());
delta.extend_from_slice(&(suffix_len as u64).to_le_bytes());
delta.extend_from_slice(&(target.len() as u64).to_le_bytes());
delta.extend_from_slice(changed);
(base.to_vec(), delta)
} else {
(base.to_vec(), target.to_vec())
}
}
pub fn apply_delta(base: &[u8], delta: &[u8]) -> Vec<u8> {
if delta.len() < 24 {
return delta.to_vec();
}
let prefix_len = u64::from_le_bytes(delta[0..8].try_into().unwrap_or([0; 8])) as usize;
let suffix_len = u64::from_le_bytes(delta[8..16].try_into().unwrap_or([0; 8])) as usize;
let total_len = u64::from_le_bytes(delta[16..24].try_into().unwrap_or([0; 8])) as usize;
let changed = &delta[24..];
let mut result = Vec::with_capacity(total_len);
result.extend_from_slice(&base[..prefix_len.min(base.len())]);
result.extend_from_slice(changed);
result.extend_from_slice(&base[base.len().saturating_sub(suffix_len)..]);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip<T: Serialize + for<'de> Deserialize<'de>>(val: &T) -> T {
let json = serde_json::to_string(val).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
fn make_hash(hex: &str) -> HashProto {
HashProto {
value: hex.to_string(),
}
}
fn make_patch(id: &str, op: &str, parents: &[&str]) -> PatchProto {
PatchProto {
id: make_hash(id),
operation_type: op.to_string(),
touch_set: vec![format!("file_{id}")],
target_path: Some(format!("file_{id}")),
payload: String::new(),
parent_ids: parents.iter().map(|p| make_hash(p)).collect(),
author: "alice".to_string(),
message: format!("patch {id}"),
timestamp: 1000,
}
}
fn make_branch(name: &str, target: &str) -> BranchProto {
BranchProto {
name: name.to_string(),
target_id: make_hash(target),
}
}
#[test]
fn test_handshake_roundtrip() {
let req = HandshakeRequest {
client_version: 1,
client_name: "test".to_string(),
};
let rt: HandshakeRequest = roundtrip(&req);
assert_eq!(rt.client_version, 1);
assert_eq!(rt.client_name, "test");
let resp = HandshakeResponse {
server_version: 1,
server_name: "hub".to_string(),
compatible: true,
};
let rt: HandshakeResponse = roundtrip(&resp);
assert!(rt.compatible);
}
#[test]
fn test_auth_method_roundtrip() {
let methods = vec![
AuthMethod::None,
AuthMethod::Signature {
public_key: "pk".to_string(),
signature: "sig".to_string(),
},
AuthMethod::Token("tok".to_string()),
];
for m in &methods {
let rt: AuthMethod = roundtrip(m);
match (m, &rt) {
(AuthMethod::None, AuthMethod::None) => {}
(
AuthMethod::Signature {
public_key: a,
signature: b,
},
AuthMethod::Signature {
public_key: c,
signature: d,
},
) => {
assert_eq!(a, c);
assert_eq!(b, d);
}
(AuthMethod::Token(a), AuthMethod::Token(b)) => assert_eq!(a, b),
_ => panic!("auth method mismatch"),
}
}
}
#[test]
fn test_patch_proto_roundtrip() {
let p = make_patch("a".repeat(64).as_str(), "Create", &[]);
let rt: PatchProto = roundtrip(&p);
assert_eq!(rt.operation_type, "Create");
assert_eq!(rt.touch_set.len(), 1);
assert!(rt.target_path.is_some());
assert!(rt.parent_ids.is_empty());
assert_eq!(rt.author, "alice");
}
#[test]
fn test_patch_proto_with_parents() {
let parent = "b".repeat(64);
let p = make_patch("a".repeat(64).as_str(), "Modify", &[&parent]);
let rt: PatchProto = roundtrip(&p);
assert_eq!(rt.parent_ids.len(), 1);
assert_eq!(hash_to_hex(&rt.parent_ids[0]), parent);
}
#[test]
fn test_push_request_roundtrip() {
let req = PushRequest {
repo_id: "my-repo".to_string(),
patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
branches: vec![make_branch("main", "a".repeat(64).as_str())],
blobs: vec![BlobRef {
hash: make_hash("deadbeef"),
data: "aGVsbG8=".to_string(),
}],
signature: Some(vec![1u8; 64]),
known_branches: Some(vec![make_branch("main", "prev".repeat(32).as_str())]),
force: true,
};
let rt: PushRequest = roundtrip(&req);
assert_eq!(rt.repo_id, "my-repo");
assert_eq!(rt.patches.len(), 1);
assert_eq!(rt.branches.len(), 1);
assert_eq!(rt.blobs.len(), 1);
assert!(rt.signature.is_some());
assert!(rt.known_branches.is_some());
assert!(rt.force);
}
#[test]
fn test_push_request_defaults() {
let req = PushRequest {
repo_id: "r".to_string(),
patches: vec![],
branches: vec![],
blobs: vec![],
signature: None,
known_branches: None,
force: false,
};
let json = serde_json::to_string(&req).unwrap();
let rt: PushRequest = serde_json::from_str(&json).unwrap();
assert!(rt.signature.is_none());
assert!(rt.known_branches.is_none());
assert!(!rt.force);
}
#[test]
fn test_pull_request_roundtrip() {
let req = PullRequest {
repo_id: "r".to_string(),
known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
max_depth: Some(10),
};
let rt: PullRequest = roundtrip(&req);
assert_eq!(rt.max_depth, Some(10));
let req2 = PullRequest {
repo_id: "r".to_string(),
known_branches: vec![],
max_depth: None,
};
let rt2: PullRequest = roundtrip(&req2);
assert!(rt2.max_depth.is_none());
}
#[test]
fn test_pull_response_roundtrip() {
let resp = PullResponse {
success: true,
error: None,
patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
branches: vec![make_branch("main", "a".repeat(64).as_str())],
blobs: vec![BlobRef {
hash: make_hash("abc"),
data: "dGVzdA==".to_string(),
}],
};
let rt: PullResponse = roundtrip(&resp);
assert!(rt.success);
assert_eq!(rt.patches.len(), 1);
assert_eq!(rt.blobs.len(), 1);
}
#[test]
fn test_pull_response_error() {
let resp = PullResponse {
success: false,
error: Some("not found".to_string()),
patches: vec![],
branches: vec![],
blobs: vec![],
};
let rt: PullResponse = roundtrip(&resp);
assert!(!rt.success);
assert_eq!(rt.error, Some("not found".to_string()));
}
#[test]
fn test_blob_ref_roundtrip() {
let blob = BlobRef {
hash: make_hash("cafebabe"),
data: "SGVsbG8gV29ybGQ=".to_string(),
};
let rt: BlobRef = roundtrip(&blob);
assert_eq!(rt.data, "SGVsbG8gV29ybGQ=");
}
#[test]
fn test_hash_helpers() {
let h = hex_to_hash("abcdef1234");
assert_eq!(hash_to_hex(&h), "abcdef1234");
}
#[test]
fn test_canonical_push_bytes_deterministic() {
let req = PushRequest {
repo_id: "test".to_string(),
patches: vec![make_patch("a".repeat(64).as_str(), "Create", &[])],
branches: vec![make_branch("main", "a".repeat(64).as_str())],
blobs: vec![],
signature: None,
known_branches: None,
force: false,
};
let b1 = canonical_push_bytes(&req);
let b2 = canonical_push_bytes(&req);
assert_eq!(b1, b2);
}
#[test]
fn test_canonical_push_bytes_different_repos() {
let make_req = |repo: &str| PushRequest {
repo_id: repo.to_string(),
patches: vec![],
branches: vec![],
blobs: vec![],
signature: None,
known_branches: None,
force: false,
};
let b1 = canonical_push_bytes(&make_req("repo-a"));
let b2 = canonical_push_bytes(&make_req("repo-b"));
assert_ne!(b1, b2);
}
#[test]
fn test_repo_info_response_roundtrip() {
let resp = RepoInfoResponse {
repo_id: "my-repo".to_string(),
patch_count: 42,
branches: vec![make_branch("main", "a".repeat(32).as_str())],
success: true,
error: None,
};
let rt: RepoInfoResponse = roundtrip(&resp);
assert_eq!(rt.patch_count, 42);
assert!(rt.success);
let err = RepoInfoResponse {
repo_id: "x".to_string(),
patch_count: 0,
branches: vec![],
success: false,
error: Some("not found".to_string()),
};
let rt2: RepoInfoResponse = roundtrip(&err);
assert!(!rt2.success);
assert_eq!(rt2.error, Some("not found".to_string()));
}
#[test]
fn test_list_repos_response_roundtrip() {
let resp = ListReposResponse {
repo_ids: vec!["a".to_string(), "b".to_string()],
};
let rt: ListReposResponse = roundtrip(&resp);
assert_eq!(rt.repo_ids, vec!["a", "b"]);
}
#[test]
fn test_push_response_roundtrip() {
let resp = PushResponse {
success: true,
error: None,
existing_patches: vec![make_hash("abc"), make_hash("def")],
};
let rt: PushResponse = roundtrip(&resp);
assert_eq!(rt.existing_patches.len(), 2);
}
#[test]
fn test_delta_roundtrip() {
let base = b"Hello, World!";
let target = b"Hello, Rust!";
let (_base_copy, delta) = compute_delta(base, target);
let result = apply_delta(base, &delta);
assert_eq!(result, target);
}
#[test]
fn test_delta_no_change() {
let base = b"identical data here";
let target = b"identical data here";
let (_base_copy, delta) = compute_delta(base, target);
assert!(delta.len() < target.len() + 24);
let result = apply_delta(base, &delta);
assert_eq!(result, target);
}
#[test]
fn test_delta_completely_different() {
let base = b"AAAA";
let target = b"BBBB";
let (_base_copy, delta) = compute_delta(base, target);
let result = apply_delta(base, &delta);
assert_eq!(result, target);
}
#[test]
fn test_pull_request_v2_roundtrip() {
let req = PullRequestV2 {
repo_id: "my-repo".to_string(),
known_branches: vec![make_branch("main", "a".repeat(32).as_str())],
max_depth: Some(10),
known_blob_hashes: vec![make_hash("deadbeef")],
capabilities: ClientCapabilities {
supports_delta: true,
supports_compression: true,
max_blob_size: 1024 * 1024,
},
};
let rt: PullRequestV2 = roundtrip(&req);
assert_eq!(rt.repo_id, "my-repo");
assert_eq!(rt.max_depth, Some(10));
assert!(rt.capabilities.supports_delta);
assert_eq!(rt.known_blob_hashes.len(), 1);
}
#[test]
fn test_handshake_v2_roundtrip() {
let req = HandshakeRequestV2 {
client_version: 2,
client_name: "suture-cli".to_string(),
capabilities: ClientCapabilities {
supports_delta: true,
supports_compression: false,
max_blob_size: 512 * 1024,
},
};
let rt: HandshakeRequestV2 = roundtrip(&req);
assert_eq!(rt.client_version, 2);
assert!(rt.capabilities.supports_delta);
assert!(!rt.capabilities.supports_compression);
let resp = HandshakeResponseV2 {
server_version: 2,
server_name: "suture-hub".to_string(),
compatible: true,
server_capabilities: ServerCapabilities {
supports_delta: true,
supports_compression: true,
max_blob_size: 10 * 1024 * 1024,
protocol_versions: vec![1, 2],
},
};
let rt: HandshakeResponseV2 = roundtrip(&resp);
assert!(rt.compatible);
assert_eq!(rt.server_capabilities.protocol_versions, vec![1, 2]);
}
#[test]
fn test_client_capabilities_roundtrip() {
let caps = ClientCapabilities {
supports_delta: false,
supports_compression: true,
max_blob_size: 999,
};
let rt: ClientCapabilities = roundtrip(&caps);
assert!(!rt.supports_delta);
assert!(rt.supports_compression);
assert_eq!(rt.max_blob_size, 999);
}
#[test]
fn test_blob_delta_roundtrip() {
let delta = BlobDelta {
base_hash: make_hash("aaa"),
target_hash: make_hash("bbb"),
encoding: DeltaEncoding::BinaryPatch,
delta_data: "ZGF0YQ==".to_string(),
};
let rt: BlobDelta = roundtrip(&delta);
assert_eq!(hash_to_hex(&rt.base_hash), "aaa");
assert_eq!(hash_to_hex(&rt.target_hash), "bbb");
assert!(matches!(rt.encoding, DeltaEncoding::BinaryPatch));
assert_eq!(rt.delta_data, "ZGF0YQ==");
let full = BlobDelta {
base_hash: make_hash("aaa"),
target_hash: make_hash("bbb"),
encoding: DeltaEncoding::FullBlob,
delta_data: "Ynl0ZXM=".to_string(),
};
let rt: BlobDelta = roundtrip(&full);
assert!(matches!(rt.encoding, DeltaEncoding::FullBlob));
}
}