heroforge_core/sync/
protocol.rs

1//! Heroforge sync protocol implementation.
2//!
3//! This implements the wire protocol for Heroforge synchronization.
4
5use crate::error::Result;
6use crate::hash;
7use flate2::read::ZlibDecoder;
8use flate2::write::ZlibEncoder;
9use flate2::Compression;
10use std::io::{Read, Write};
11
12/// A card in the Heroforge sync protocol.
13#[derive(Debug, Clone)]
14pub enum Card {
15    /// Login card: login userid nonce signature
16    Login {
17        userid: String,
18        nonce: String,
19        signature: String,
20    },
21    /// Push card: push servercode projectcode
22    Push {
23        servercode: String,
24        projectcode: String,
25    },
26    /// Pull card: pull servercode projectcode
27    Pull {
28        servercode: String,
29        projectcode: String,
30    },
31    /// Clone card: clone [protocol-version sequence-number]
32    Clone {
33        protocol_version: Option<u32>,
34        sequence_number: Option<u64>,
35    },
36    /// File card: file artifact-id [delta-source-id] size \n content
37    File {
38        artifact_id: String,
39        delta_source: Option<String>,
40        content: Vec<u8>,
41    },
42    /// Compressed file card (for clone protocol 3+)
43    CFile {
44        artifact_id: String,
45        delta_source: Option<String>,
46        uncompressed_size: usize,
47        content: Vec<u8>,
48    },
49    /// Igot card: igot artifact-id [private-flag]
50    Igot {
51        artifact_id: String,
52        is_private: bool,
53    },
54    /// Gimme card: gimme artifact-id
55    Gimme { artifact_id: String },
56    /// Cookie card: cookie payload
57    Cookie { payload: String },
58    /// Error card: error message
59    Error { message: String },
60    /// Message card: message text
61    Message { text: String },
62    /// Pragma card: pragma name value...
63    Pragma { name: String, values: Vec<String> },
64    /// Comment card: # text
65    Comment { text: String },
66    /// Private marker (precedes a file card)
67    Private,
68    /// Clone sequence number response
69    CloneSeqNo { sequence_number: u64 },
70    /// Request config card
71    ReqConfig { name: String },
72    /// Config card with content
73    Config { name: String, content: Vec<u8> },
74}
75
76/// A sync protocol message (request or response).
77#[derive(Debug, Clone, Default)]
78pub struct Message {
79    pub cards: Vec<Card>,
80}
81
82impl Message {
83    /// Create a new empty message.
84    pub fn new() -> Self {
85        Self { cards: Vec::new() }
86    }
87
88    /// Add a card to the message.
89    pub fn add(&mut self, card: Card) {
90        self.cards.push(card);
91    }
92
93    /// Encode the message to bytes (compressed with size prefix).
94    ///
95    /// Heroforge's HTTP sync protocol uses a 4-byte big-endian size prefix
96    /// (uncompressed size) before the zlib-compressed data.
97    pub fn encode(&self) -> Result<Vec<u8>> {
98        let text = self.to_text()?;
99        let uncompressed_size = text.len() as u32;
100        let compressed = compress(text.as_bytes())?;
101
102        // Build result with 4-byte size prefix
103        let mut result = Vec::with_capacity(4 + compressed.len());
104        result.extend_from_slice(&uncompressed_size.to_be_bytes());
105        result.extend_from_slice(&compressed);
106
107        Ok(result)
108    }
109
110    /// Encode the message to text (uncompressed, for debugging).
111    pub fn to_text(&self) -> Result<String> {
112        let mut lines: Vec<String> = Vec::new();
113
114        for card in &self.cards {
115            lines.push(card.to_line()?);
116        }
117
118        Ok(lines.join(""))
119    }
120
121    /// Decode a message from compressed bytes.
122    ///
123    /// This properly handles binary file content within the message.
124    pub fn decode(data: &[u8]) -> Result<Self> {
125        let bytes = decompress_bytes(data)?;
126        Self::from_bytes(&bytes)
127    }
128
129    /// Parse a message from uncompressed bytes.
130    ///
131    /// This handles binary file/cfile content properly.
132    pub fn from_bytes(data: &[u8]) -> Result<Self> {
133        let mut message = Message::new();
134        let mut pos = 0;
135
136        while pos < data.len() {
137            // Skip whitespace
138            while pos < data.len()
139                && (data[pos] == b' ' || data[pos] == b'\n' || data[pos] == b'\r')
140            {
141                pos += 1;
142            }
143            if pos >= data.len() {
144                break;
145            }
146
147            // Find end of line
148            let line_start = pos;
149            while pos < data.len() && data[pos] != b'\n' {
150                pos += 1;
151            }
152            let line_end = pos;
153            if pos < data.len() {
154                pos += 1; // Skip newline
155            }
156
157            let line = String::from_utf8_lossy(&data[line_start..line_end]);
158            let line = line.trim();
159            if line.is_empty() {
160                continue;
161            }
162
163            let parts: Vec<&str> = line.splitn(2, ' ').collect();
164            let op = parts[0];
165            let args = parts.get(1).unwrap_or(&"");
166
167            match op {
168                "file" | "cfile" => {
169                    // file artifact-id size                    (3 parts total, 2 args)
170                    // file artifact-id delta-src size          (4 parts total, 3 args)
171                    // cfile artifact-id size usize             (4 parts total, 3 args)
172                    // cfile artifact-id delta-src size usize   (5 parts total, 4 args)
173                    let parts: Vec<&str> = args.split_whitespace().collect();
174                    if parts.len() >= 2 {
175                        let (artifact_id, delta_source, size) = if op == "cfile" {
176                            // cfile: artifact-id [delta-src] size usize
177                            // parts[0] = artifact-id
178                            // If 4+ parts: parts[1]=delta, parts[2]=size, parts[3]=usize
179                            // If 3 parts: parts[1]=size, parts[2]=usize (no delta)
180                            if parts.len() >= 4 {
181                                (
182                                    parts[0].to_string(),
183                                    Some(parts[1].to_string()),
184                                    parts[2].parse::<usize>().unwrap_or(0),
185                                )
186                            } else if parts.len() == 3 {
187                                (
188                                    parts[0].to_string(),
189                                    None,
190                                    parts[1].parse::<usize>().unwrap_or(0),
191                                )
192                            } else {
193                                continue;
194                            }
195                        } else {
196                            // file: artifact-id [delta-src] size
197                            // parts[0] = artifact-id
198                            // If 3+ parts: check if parts[1] looks like a hash (delta) or number (size)
199                            // If 2 parts: parts[1] = size (no delta)
200                            if parts.len() >= 3 {
201                                // Could be "artifact delta size" or misparse
202                                // Delta source is a 64-char hash, size is a number
203                                if parts[1].len() == 64
204                                    && parts[1].chars().all(|c| c.is_ascii_hexdigit())
205                                {
206                                    // parts[1] is a hash -> delta source
207                                    (
208                                        parts[0].to_string(),
209                                        Some(parts[1].to_string()),
210                                        parts[2].parse::<usize>().unwrap_or(0),
211                                    )
212                                } else {
213                                    // parts[1] is size, no delta
214                                    (
215                                        parts[0].to_string(),
216                                        None,
217                                        parts[1].parse::<usize>().unwrap_or(0),
218                                    )
219                                }
220                            } else {
221                                (
222                                    parts[0].to_string(),
223                                    None,
224                                    parts[1].parse::<usize>().unwrap_or(0),
225                                )
226                            }
227                        };
228
229                        // Read the content bytes
230                        let content_end = (pos + size).min(data.len());
231                        let content = data[pos..content_end].to_vec();
232                        pos = content_end;
233
234                        if op == "cfile" {
235                            message.add(Card::CFile {
236                                artifact_id,
237                                delta_source,
238                                content,
239                                uncompressed_size: parts
240                                    .last()
241                                    .and_then(|s| s.parse().ok())
242                                    .unwrap_or(0),
243                            });
244                        } else {
245                            message.add(Card::File {
246                                artifact_id,
247                                delta_source,
248                                content,
249                            });
250                        }
251                    }
252                }
253                _ => {
254                    // Handle other cards via text parsing
255                    if let Some(card) = Self::parse_text_card(op, args) {
256                        message.add(card);
257                    }
258                }
259            }
260        }
261
262        Ok(message)
263    }
264
265    /// Parse a non-file card from text.
266    fn parse_text_card(op: &str, args: &str) -> Option<Card> {
267        match op {
268            "login" => {
269                let parts: Vec<&str> = args.split_whitespace().collect();
270                if parts.len() >= 3 {
271                    Some(Card::Login {
272                        userid: decode_fossil_string(parts[0]),
273                        nonce: parts[1].to_string(),
274                        signature: parts[2].to_string(),
275                    })
276                } else {
277                    None
278                }
279            }
280            "push" => {
281                let parts: Vec<&str> = args.split_whitespace().collect();
282                if parts.len() >= 2 {
283                    Some(Card::Push {
284                        servercode: parts[0].to_string(),
285                        projectcode: parts[1].to_string(),
286                    })
287                } else {
288                    None
289                }
290            }
291            "pull" => {
292                let parts: Vec<&str> = args.split_whitespace().collect();
293                if parts.len() >= 2 {
294                    Some(Card::Pull {
295                        servercode: parts[0].to_string(),
296                        projectcode: parts[1].to_string(),
297                    })
298                } else {
299                    None
300                }
301            }
302            "clone" => {
303                let parts: Vec<&str> = args.split_whitespace().collect();
304                Some(Card::Clone {
305                    protocol_version: parts.first().and_then(|s| s.parse().ok()),
306                    sequence_number: parts.get(1).and_then(|s| s.parse().ok()),
307                })
308            }
309            "clone_seqno" => Some(Card::CloneSeqNo {
310                sequence_number: args.trim().parse().unwrap_or(0),
311            }),
312            "igot" => {
313                let parts: Vec<&str> = args.split_whitespace().collect();
314                if !parts.is_empty() {
315                    Some(Card::Igot {
316                        artifact_id: parts[0].to_string(),
317                        is_private: parts.get(1).map(|&s| s == "1").unwrap_or(false),
318                    })
319                } else {
320                    None
321                }
322            }
323            "gimme" => Some(Card::Gimme {
324                artifact_id: args.trim().to_string(),
325            }),
326            "cookie" => Some(Card::Cookie {
327                payload: decode_fossil_string(args.trim()),
328            }),
329            "error" => Some(Card::Error {
330                message: decode_fossil_string(args.trim()),
331            }),
332            "pragma" => {
333                let parts: Vec<&str> = args.split_whitespace().collect();
334                if !parts.is_empty() {
335                    Some(Card::Pragma {
336                        name: parts[0].to_string(),
337                        values: parts[1..].iter().map(|s| s.to_string()).collect(),
338                    })
339                } else {
340                    None
341                }
342            }
343            _ => {
344                // Unknown card type - ignore
345                None
346            }
347        }
348    }
349
350    /// Parse a message from uncompressed text.
351    pub fn from_text(text: &str) -> Result<Self> {
352        let mut message = Message::new();
353        let mut lines = text.lines().peekable();
354
355        while let Some(line) = lines.next() {
356            let line = line.trim();
357            if line.is_empty() {
358                continue;
359            }
360
361            let parts: Vec<&str> = line.splitn(2, ' ').collect();
362            let op = parts[0];
363            let args = parts.get(1).unwrap_or(&"");
364
365            match op {
366                "login" => {
367                    let parts: Vec<&str> = args.split_whitespace().collect();
368                    if parts.len() >= 3 {
369                        message.add(Card::Login {
370                            userid: decode_fossil_string(parts[0]),
371                            nonce: parts[1].to_string(),
372                            signature: parts[2].to_string(),
373                        });
374                    }
375                }
376                "push" => {
377                    let parts: Vec<&str> = args.split_whitespace().collect();
378                    if parts.len() >= 2 {
379                        message.add(Card::Push {
380                            servercode: parts[0].to_string(),
381                            projectcode: parts[1].to_string(),
382                        });
383                    }
384                }
385                "pull" => {
386                    let parts: Vec<&str> = args.split_whitespace().collect();
387                    if parts.len() >= 2 {
388                        message.add(Card::Pull {
389                            servercode: parts[0].to_string(),
390                            projectcode: parts[1].to_string(),
391                        });
392                    }
393                }
394                "clone" => {
395                    let parts: Vec<&str> = args.split_whitespace().collect();
396                    if parts.len() >= 2 {
397                        message.add(Card::Clone {
398                            protocol_version: parts[0].parse().ok(),
399                            sequence_number: parts[1].parse().ok(),
400                        });
401                    } else {
402                        message.add(Card::Clone {
403                            protocol_version: None,
404                            sequence_number: None,
405                        });
406                    }
407                }
408                "file" => {
409                    let parts: Vec<&str> = args.split_whitespace().collect();
410                    if parts.len() >= 2 {
411                        let (artifact_id, delta_source, _size) = if parts.len() == 2 {
412                            (
413                                parts[0].to_string(),
414                                None,
415                                parts[1].parse::<usize>().unwrap_or(0),
416                            )
417                        } else {
418                            (
419                                parts[0].to_string(),
420                                Some(parts[1].to_string()),
421                                parts[2].parse::<usize>().unwrap_or(0),
422                            )
423                        };
424
425                        // Read inline content - this is tricky with lines iterator
426                        // For now, we'll handle this in a more complete parser
427                        message.add(Card::File {
428                            artifact_id,
429                            delta_source,
430                            content: Vec::new(), // Content parsed separately
431                        });
432                    }
433                }
434                "igot" => {
435                    let parts: Vec<&str> = args.split_whitespace().collect();
436                    if !parts.is_empty() {
437                        let is_private = parts.get(1).map(|&s| s == "1").unwrap_or(false);
438                        message.add(Card::Igot {
439                            artifact_id: parts[0].to_string(),
440                            is_private,
441                        });
442                    }
443                }
444                "gimme" => {
445                    message.add(Card::Gimme {
446                        artifact_id: args.trim().to_string(),
447                    });
448                }
449                "cookie" => {
450                    message.add(Card::Cookie {
451                        payload: args.to_string(),
452                    });
453                }
454                "error" => {
455                    message.add(Card::Error {
456                        message: decode_fossil_string(args),
457                    });
458                }
459                "message" => {
460                    message.add(Card::Message {
461                        text: decode_fossil_string(args),
462                    });
463                }
464                "pragma" => {
465                    let parts: Vec<&str> = args.split_whitespace().collect();
466                    if !parts.is_empty() {
467                        message.add(Card::Pragma {
468                            name: parts[0].to_string(),
469                            values: parts[1..].iter().map(|s| s.to_string()).collect(),
470                        });
471                    }
472                }
473                "private" => {
474                    message.add(Card::Private);
475                }
476                "clone_seqno" => {
477                    if let Ok(seq) = args.trim().parse() {
478                        message.add(Card::CloneSeqNo {
479                            sequence_number: seq,
480                        });
481                    }
482                }
483                "reqconfig" => {
484                    message.add(Card::ReqConfig {
485                        name: args.trim().to_string(),
486                    });
487                }
488                _ if op.starts_with('#') => {
489                    message.add(Card::Comment {
490                        text: line.to_string(),
491                    });
492                }
493                _ => {
494                    // Unknown card type - skip
495                }
496            }
497        }
498
499        Ok(message)
500    }
501
502    /// Create a login card with proper signature.
503    pub fn create_login(userid: &str, password: &str, payload: &str) -> Card {
504        // nonce = SHA1(payload)
505        let nonce = hash::sha1_hex(payload.as_bytes());
506        // signature = SHA1(nonce + password)
507        let sig_input = format!("{}{}", nonce, password);
508        let signature = hash::sha1_hex(sig_input.as_bytes());
509
510        Card::Login {
511            userid: userid.to_string(),
512            nonce,
513            signature,
514        }
515    }
516}
517
518impl Card {
519    /// Convert a card to its line representation.
520    pub fn to_line(&self) -> Result<String> {
521        match self {
522            Card::Login {
523                userid,
524                nonce,
525                signature,
526            } => Ok(format!(
527                "login {} {} {}\n",
528                encode_fossil_string(userid),
529                nonce,
530                signature
531            )),
532            Card::Push {
533                servercode,
534                projectcode,
535            } => Ok(format!("push {} {}\n", servercode, projectcode)),
536            Card::Pull {
537                servercode,
538                projectcode,
539            } => Ok(format!("pull {} {}\n", servercode, projectcode)),
540            Card::Clone {
541                protocol_version,
542                sequence_number,
543            } => {
544                if let (Some(pv), Some(seq)) = (protocol_version, sequence_number) {
545                    Ok(format!("clone {} {}\n", pv, seq))
546                } else {
547                    Ok("clone\n".to_string())
548                }
549            }
550            Card::File {
551                artifact_id,
552                delta_source,
553                content,
554            } => {
555                if let Some(delta) = delta_source {
556                    Ok(format!(
557                        "file {} {} {}\n",
558                        artifact_id,
559                        delta,
560                        content.len()
561                    ))
562                } else {
563                    Ok(format!("file {} {}\n", artifact_id, content.len()))
564                }
565            }
566            Card::CFile {
567                artifact_id,
568                delta_source,
569                uncompressed_size,
570                content,
571            } => {
572                if let Some(delta) = delta_source {
573                    Ok(format!(
574                        "cfile {} {} {} {}\n",
575                        artifact_id,
576                        delta,
577                        uncompressed_size,
578                        content.len()
579                    ))
580                } else {
581                    Ok(format!(
582                        "cfile {} {} {}\n",
583                        artifact_id,
584                        uncompressed_size,
585                        content.len()
586                    ))
587                }
588            }
589            Card::Igot {
590                artifact_id,
591                is_private,
592            } => {
593                if *is_private {
594                    Ok(format!("igot {} 1\n", artifact_id))
595                } else {
596                    Ok(format!("igot {}\n", artifact_id))
597                }
598            }
599            Card::Gimme { artifact_id } => Ok(format!("gimme {}\n", artifact_id)),
600            Card::Cookie { payload } => Ok(format!("cookie {}\n", payload)),
601            Card::Error { message } => Ok(format!("error {}\n", encode_fossil_string(message))),
602            Card::Message { text } => Ok(format!("message {}\n", encode_fossil_string(text))),
603            Card::Pragma { name, values } => {
604                if values.is_empty() {
605                    Ok(format!("pragma {}\n", name))
606                } else {
607                    Ok(format!("pragma {} {}\n", name, values.join(" ")))
608                }
609            }
610            Card::Comment { text } => Ok(format!("{}\n", text)),
611            Card::Private => Ok("private\n".to_string()),
612            Card::CloneSeqNo { sequence_number } => {
613                Ok(format!("clone_seqno {}\n", sequence_number))
614            }
615            Card::ReqConfig { name } => Ok(format!("reqconfig {}\n", name)),
616            Card::Config { name, content } => Ok(format!("config {} {}\n", name, content.len())),
617        }
618    }
619
620    /// Get file content bytes for file/cfile cards.
621    pub fn content_bytes(&self) -> Option<&[u8]> {
622        match self {
623            Card::File { content, .. } => Some(content),
624            Card::CFile { content, .. } => Some(content),
625            Card::Config { content, .. } => Some(content),
626            _ => None,
627        }
628    }
629}
630
631/// Encode a string for Heroforge protocol (escape spaces and special chars).
632pub fn encode_fossil_string(s: &str) -> String {
633    s.replace('\\', "\\\\")
634        .replace(' ', "\\s")
635        .replace('\n', "\\n")
636}
637
638/// Decode a Heroforge-encoded string.
639pub fn decode_fossil_string(s: &str) -> String {
640    let mut result = String::new();
641    let mut chars = s.chars().peekable();
642
643    while let Some(c) = chars.next() {
644        if c == '\\' {
645            match chars.next() {
646                Some('s') => result.push(' '),
647                Some('n') => result.push('\n'),
648                Some('\\') => result.push('\\'),
649                Some(other) => {
650                    result.push('\\');
651                    result.push(other);
652                }
653                None => result.push('\\'),
654            }
655        } else {
656            result.push(c);
657        }
658    }
659
660    result
661}
662
663/// Compress data using zlib.
664pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
665    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
666    encoder.write_all(data)?;
667    Ok(encoder.finish()?)
668}
669
670/// Get the zlib-compressed data, stripping any size prefix.
671fn get_compressed_data(data: &[u8]) -> &[u8] {
672    if data.len() >= 5 {
673        // Check if this looks like a size prefix followed by zlib header
674        // Zlib header starts with 0x78 (deflate with default/max compression)
675        if data[4] == 0x78 {
676            // Has size prefix, skip it
677            return &data[4..];
678        }
679    }
680    if !data.is_empty() && data[0] == 0x78 {
681        // No prefix, starts with zlib header directly
682        return data;
683    }
684    // Try as-is
685    data
686}
687
688/// Decompress zlib data to bytes.
689///
690/// Heroforge's HTTP sync protocol uses a 4-byte big-endian size prefix
691/// before the zlib-compressed data.
692pub fn decompress_bytes(data: &[u8]) -> Result<Vec<u8>> {
693    let compressed_data = get_compressed_data(data);
694    let mut decoder = ZlibDecoder::new(compressed_data);
695    let mut result = Vec::new();
696    decoder.read_to_end(&mut result)?;
697    Ok(result)
698}
699
700/// Decompress zlib data to string.
701///
702/// Heroforge's HTTP sync protocol uses a 4-byte big-endian size prefix
703/// before the zlib-compressed data.
704#[allow(dead_code)]
705pub fn decompress(data: &[u8]) -> Result<String> {
706    let compressed_data = get_compressed_data(data);
707    let mut decoder = ZlibDecoder::new(compressed_data);
708    let mut result = String::new();
709    decoder.read_to_string(&mut result)?;
710    Ok(result)
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    #[test]
718    fn test_encode_decode_string() {
719        assert_eq!(encode_fossil_string("hello world"), "hello\\sworld");
720        assert_eq!(encode_fossil_string("line1\nline2"), "line1\\nline2");
721        assert_eq!(decode_fossil_string("hello\\sworld"), "hello world");
722        assert_eq!(decode_fossil_string("line1\\nline2"), "line1\nline2");
723    }
724
725    #[test]
726    fn test_message_parse() {
727        let text = "push abc123 def456\nigot hash1\nigot hash2 1\ngimme hash3\n";
728        let msg = Message::from_text(text).unwrap();
729        assert_eq!(msg.cards.len(), 4);
730    }
731
732    #[test]
733    fn test_compress_decompress() {
734        let original = "Hello, Heroforge sync protocol!";
735        let compressed = compress(original.as_bytes()).unwrap();
736        let decompressed = decompress(&compressed).unwrap();
737        assert_eq!(decompressed, original);
738    }
739}