pub const DEFAULT_LINE_LENGTH: u8 = 128;
#[must_use]
pub fn encode(data: &[u8], filename: &str, line_length: u8) -> Vec<u8> {
let line_length = line_length.max(2) as usize;
let mut out = Vec::with_capacity(data.len().saturating_mul(11) / 10 + 128);
out.extend_from_slice(
format!(
"=ybegin line={line_length} size={} name={filename}\r\n",
data.len()
)
.as_bytes(),
);
let crc = encode_body(data, line_length, &mut out);
out.extend_from_slice(format!("=yend size={} crc32={crc:08x}\r\n", data.len()).as_bytes());
out
}
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn encode_part(
data: &[u8],
filename: &str,
total_size: u64,
total_parts: u32,
part: u32,
begin: u64,
end: u64,
whole_file_crc32: u32,
line_length: u8,
) -> Vec<u8> {
let line_length = line_length.max(2) as usize;
let mut out = Vec::with_capacity(data.len().saturating_mul(11) / 10 + 256);
out.extend_from_slice(
format!(
"=ybegin part={part} total={total_parts} line={line_length} \
size={total_size} name={filename}\r\n"
)
.as_bytes(),
);
out.extend_from_slice(format!("=ypart begin={begin} end={end}\r\n").as_bytes());
let pcrc = encode_body(data, line_length, &mut out);
out.extend_from_slice(
format!(
"=yend size={} part={part} pcrc32={pcrc:08x} crc32={whole_file_crc32:08x}\r\n",
data.len()
)
.as_bytes(),
);
out
}
fn encode_body(data: &[u8], line_length: usize, out: &mut Vec<u8>) -> u32 {
let mut hasher = crc32fast::Hasher::new();
hasher.update(data);
let crc = hasher.finalize();
let mut col: usize = 0;
for &byte in data {
let encoded = byte.wrapping_add(42);
let must_escape = matches!(encoded, 0x00 | 0x0A | 0x0D | 0x3D)
|| (col == 0 && matches!(encoded, 0x2E | 0x09));
if must_escape {
if col + 2 > line_length {
out.extend_from_slice(b"\r\n");
col = 0;
}
out.push(b'=');
out.push(encoded.wrapping_add(64));
col += 2;
} else {
out.push(encoded);
col += 1;
}
if col >= line_length {
out.extend_from_slice(b"\r\n");
col = 0;
}
}
if col > 0 {
out.extend_from_slice(b"\r\n");
}
crc
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decode::decode;
#[test]
fn encode_simple_bytes() {
let mut out = Vec::new();
let crc = encode_body(&[0, 1, 2, 3], 128, &mut out);
assert_eq!(&out[..4], b"*+,-");
assert_eq!(crc, 0x8bb9_8613);
}
#[test]
fn encode_escapes_nul() {
let mut out = Vec::new();
encode_body(&[214], 128, &mut out);
assert_eq!(&out[..2], b"=@");
}
#[test]
fn encode_escapes_lf() {
let mut out = Vec::new();
encode_body(&[224], 128, &mut out);
assert_eq!(&out[..2], b"=J");
}
#[test]
fn encode_escapes_cr() {
let mut out = Vec::new();
encode_body(&[227], 128, &mut out);
assert_eq!(&out[..2], b"=M");
}
#[test]
fn encode_escapes_eq() {
let mut out = Vec::new();
encode_body(&[19], 128, &mut out);
assert_eq!(&out[..2], b"=}");
}
#[test]
fn encode_escapes_dot_at_line_start() {
let mut out = Vec::new();
encode_body(&[4], 128, &mut out);
assert_eq!(&out[..2], b"=n");
}
#[test]
fn encode_dot_not_escaped_mid_line() {
let mut out = Vec::new();
encode_body(&[1, 4], 128, &mut out);
assert_eq!(out[0], b'+');
assert_eq!(out[1], b'.'); }
#[test]
fn encode_line_wrapping() {
let data = vec![0u8; 8]; let mut out = Vec::new();
encode_body(&data, 4, &mut out);
assert_eq!(&out[..4], b"****");
assert_eq!(&out[4..6], b"\r\n");
assert_eq!(&out[6..10], b"****");
assert_eq!(&out[10..12], b"\r\n");
}
#[test]
fn encode_dot_at_line_start_uses_escape() {
let mut out = Vec::new();
encode_body(&[0x04u8], 128, &mut out);
assert_eq!(
&out[..2],
b"=n",
"dot (raw 0x04) at line start must encode as '=n'"
);
}
#[test]
fn encode_tab_at_line_start_uses_escape() {
let mut out = Vec::new();
encode_body(&[0xDFu8], 128, &mut out);
assert_eq!(
&out[..2],
b"=I",
"TAB (raw 0xDF) at line start must encode as '=I'"
);
}
#[test]
fn encode_all_bytes_round_trip() {
let raw: Vec<u8> = (0u8..=255).collect();
let line_length: u8 = 128;
let mut expected_encoded = Vec::new();
for &b in &raw {
let v = b.wrapping_add(42);
if matches!(v, 0 | 10 | 13 | 61) {
expected_encoded.push(b'=');
expected_encoded.push(v.wrapping_add(64));
} else {
expected_encoded.push(v);
}
}
let encoded = encode(&raw, "all.bin", line_length);
let ybegin_end = encoded
.windows(2)
.position(|w| w == b"\r\n")
.expect("no \\r\\n after =ybegin")
+ 2;
let yend_start = {
let needle = b"\r\n=yend";
encoded
.windows(needle.len())
.rposition(|w| w == needle)
.expect("no \\r\\n=yend in output")
+ 2 };
let body_section = &encoded[ybegin_end..yend_start];
let mut actual_encoded: Vec<u8> = Vec::new();
for line in body_section.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if !line.is_empty() {
actual_encoded.extend_from_slice(line);
}
}
assert_eq!(
actual_encoded, expected_encoded,
"encoded body bytes do not match oracle"
);
let decoded = decode(&encoded).expect("round-trip decode failed");
assert_eq!(decoded.data, raw, "all-bytes round-trip failed");
}
#[test]
fn encode_single_part_header_footer() {
let data = b"Cat";
let out = encode(data, "cat.bin", 128);
let s = String::from_utf8_lossy(&out);
assert!(s.starts_with("=ybegin line=128 size=3 name=cat.bin\r\n"));
assert!(s.contains("=yend size=3 crc32="));
assert!(s.ends_with("\r\n"));
}
#[test]
fn encode_empty_data() {
let out = encode(b"", "empty.bin", 128);
let s = String::from_utf8_lossy(&out);
assert!(s.starts_with("=ybegin line=128 size=0 name=empty.bin\r\n"));
assert!(s.contains("=yend size=0 crc32="));
let parts: Vec<&str> = s.lines().collect();
assert_eq!(parts[0], "=ybegin line=128 size=0 name=empty.bin");
assert_eq!(parts[1], "=yend size=0 crc32=00000000");
}
#[test]
fn encode_single_part_crc_correct() {
let data: Vec<u8> = (0..64).collect();
let out = encode(&data, "test.bin", 128);
assert!(
String::from_utf8_lossy(&out).contains("crc32=100ece8c"),
"CRC32 mismatch in encoded output"
);
let decoded = decode(&out).unwrap();
assert_eq!(decoded.data, data);
assert!(decoded.crc32_verified);
}
#[test]
fn encode_part_header_fields() {
let data: Vec<u8> = (0..64).collect();
let out = encode_part(&data, "test.bin", 128, 2, 1, 1, 64, 0xdeadbeef, 128);
let s = String::from_utf8_lossy(&out);
assert!(s.starts_with("=ybegin part=1 total=2 line=128 size=128 name=test.bin\r\n"));
assert!(s.contains("=ypart begin=1 end=64\r\n"));
assert!(s.contains("pcrc32="));
assert!(s.contains("crc32=deadbeef"));
}
#[test]
fn encode_part_pcrc_is_part_crc() {
let data: Vec<u8> = (0..64).collect();
let out = encode_part(&data, "test.bin", 128, 2, 1, 1, 64, 0x24650d57, 128);
let s = String::from_utf8_lossy(&out);
assert!(s.contains("pcrc32=100ece8c"), "per-part CRC wrong: {s}");
assert!(s.contains("crc32=24650d57"), "whole-file CRC wrong: {s}");
}
#[test]
fn encode_line_length_1_clamped_to_2() {
let out = encode(&[214], "t.bin", 1);
let s = String::from_utf8_lossy(&out);
for line in s.lines() {
if line.starts_with("=ybegin") || line.starts_with("=yend") || line.is_empty() {
continue;
}
assert!(
line.len() <= 2,
"line too long with clamped line_length=2: {:?}",
line
);
}
let decoded = decode(&out).expect("round-trip decode of line_length=1 input failed");
assert_eq!(decoded.data, &[214]);
}
#[test]
fn encode_line_length_2_does_not_panic() {
let data = &[0u8, 1u8];
let out = encode(data, "t.bin", 2);
let decoded = decode(&out).expect("round-trip decode of line_length=2 input failed");
assert_eq!(decoded.data, data);
}
#[test]
fn no_line_exceeds_line_length() {
use crate::encode;
let line_length = 10u8;
let payload: Vec<u8> = (0u8..50)
.map(|i| if i % 9 == 8 { 19u8 } else { 0u8 })
.collect();
let encoded = encode(&payload, "test.bin", line_length);
for line in encoded.split(|&b| b == b'\n') {
let line = if line.ends_with(b"\r") {
&line[..line.len() - 1]
} else {
line
};
if line.starts_with(b"=ybegin") || line.starts_with(b"=yend") || line.is_empty() {
continue;
}
assert!(
line.len() <= line_length as usize,
"data line too long: {} chars (limit {}): {:?}",
line.len(),
line_length,
std::str::from_utf8(line).unwrap_or("<binary>")
);
}
}
}