use crate::error::YencError;
use crate::header::{parse_ybegin, parse_yend, parse_ypart};
use crate::{DecodedPart, YencMetadata};
pub fn decode(input: &[u8]) -> Result<DecodedPart, YencError> {
let mut lines = LineIter::new(input);
let ybegin_payload = loop {
let line = lines.next().ok_or(YencError::NoHeader)?;
if let Some(payload) = strip_keyword(line, b"=ybegin ") {
break payload;
}
};
let ybegin = parse_ybegin(lossy_str(ybegin_payload).trim())?;
let filename = ybegin.name.ok_or(YencError::InvalidHeader {
field: "name".to_string(),
})?;
let total_size = ybegin.size.ok_or(YencError::InvalidHeader {
field: "size".to_string(),
})?;
let mut part_begin: Option<u64> = None;
let mut part_end: Option<u64> = None;
let first_data_line = {
let peeked = lines.next().ok_or(YencError::UnexpectedEof)?;
if let Some(payload) = strip_keyword(peeked, b"=ypart ") {
let ypart = parse_ypart(lossy_str(payload).trim())?;
part_begin = ypart.begin;
part_end = ypart.end;
None } else {
Some(peeked) }
};
let mut decoded: Vec<u8> = Vec::new();
let mut pending_escape = false;
if let Some(line) = first_data_line {
if let Some(yend_payload) = strip_keyword(line, b"=yend ") {
return finish_decode(
decoded,
yend_payload,
filename,
total_size,
ybegin.line_length.unwrap_or(128),
ybegin.part,
ybegin.total,
part_begin,
part_end,
);
}
decode_line(line, &mut decoded, &mut pending_escape);
}
loop {
let line = lines.next().ok_or(YencError::UnexpectedEof)?;
if let Some(yend_payload) = strip_keyword(line, b"=yend ") {
return finish_decode(
decoded,
yend_payload,
filename,
total_size,
ybegin.line_length.unwrap_or(128),
ybegin.part,
ybegin.total,
part_begin,
part_end,
);
}
decode_line(line, &mut decoded, &mut pending_escape);
}
}
#[allow(clippy::too_many_arguments)]
fn finish_decode(
data: Vec<u8>,
yend_raw: &[u8],
filename: String,
total_size: u64,
line_length: u8,
part: Option<u32>,
total_parts: Option<u32>,
part_begin: Option<u64>,
part_end: Option<u64>,
) -> Result<DecodedPart, YencError> {
let yend = parse_yend(lossy_str(yend_raw).trim())?;
let crc_to_check = if part.is_some() {
yend.pcrc32
} else {
yend.crc32
};
let crc32_verified;
if let Some(expected) = crc_to_check {
let actual = crc32fast::hash(&data);
if actual != expected {
return Err(YencError::CrcMismatch { expected, actual });
}
crc32_verified = true;
} else {
crc32_verified = false;
}
if let Some(declared_size) = yend.size {
if data.len() as u64 != declared_size {
return Err(YencError::SizeMismatch {
expected: declared_size,
actual: data.len() as u64,
});
}
}
Ok(DecodedPart {
data,
metadata: YencMetadata {
filename,
size: total_size,
line_length,
total_parts,
},
part,
part_begin,
part_end,
crc32_verified,
whole_file_crc32: yend.crc32,
})
}
pub(crate) fn decode_line(line: &[u8], out: &mut Vec<u8>, pending_escape: &mut bool) {
let line = if line.len() >= 2 && line[0] == b'.' && line[1] == b'.' {
&line[1..]
} else {
line
};
let mut i = 0;
let len = line.len();
if *pending_escape {
*pending_escape = false;
while i < len && matches!(line[i], b'\r' | b'\n' | b'\0') {
i += 1;
}
if i < len {
out.push(line[i].wrapping_sub(106));
i += 1;
}
}
while i < len {
let b = line[i];
match b {
b'\r' | b'\n' | b'\0' => {
i += 1;
}
b'=' if i + 1 < len && !matches!(line[i + 1], b'\r' | b'\n' | b'\0') => {
let next = line[i + 1];
out.push(next.wrapping_sub(106));
i += 2;
}
b'=' => {
*pending_escape = true;
i += 1;
}
_ => {
out.push(b.wrapping_sub(42));
i += 1;
}
}
}
}
struct LineIter<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> LineIter<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn next(&mut self) -> Option<&'a [u8]> {
if self.pos >= self.data.len() {
return None;
}
let start = self.pos;
let nl = self.data[start..]
.iter()
.position(|&b| b == b'\n')
.map(|r| start + r);
let end = match nl {
Some(nl_pos) => nl_pos + 1, None => self.data.len(), };
self.pos = end;
Some(&self.data[start..end])
}
}
fn strip_keyword<'a>(line: &'a [u8], keyword: &[u8]) -> Option<&'a [u8]> {
let line = line
.strip_suffix(b"\r\n")
.or_else(|| line.strip_suffix(b"\n"))
.or_else(|| line.strip_suffix(b"\r"))
.unwrap_or(line);
line.strip_prefix(keyword)
}
fn lossy_str(bytes: &[u8]) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_line_simple_bytes() {
let encoded = b"*+,-";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[0, 1, 2, 3]);
assert!(!pe);
}
#[test]
fn decode_line_escape_sequence() {
let encoded = b"=}";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[19]);
assert!(!pe);
}
#[test]
fn decode_line_discards_crlf() {
let encoded = b"*+\r\n";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[0, 1]);
}
#[test]
fn decode_line_discards_nul() {
let encoded = b"*\x00+";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[0, 1]);
}
#[test]
fn decode_line_dot_stuffing() {
let encoded = b"..+";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[4, 1]);
}
#[test]
fn decode_line_no_dot_stuffing_single_dot() {
let encoded = b".+";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[4, 1]);
}
#[test]
fn decode_line_all_256_bytes() {
let raw: Vec<u8> = (0u8..=255).collect();
let mut encoded = Vec::new();
for &b in &raw {
let v = b.wrapping_add(42);
if matches!(v, 0 | 10 | 13 | 61) {
encoded.push(b'=');
encoded.push(v.wrapping_add(64));
} else {
encoded.push(v);
}
}
let mut decoded = Vec::new();
let mut pe = false;
decode_line(&encoded, &mut decoded, &mut pe);
assert_eq!(decoded, raw, "all-bytes round-trip failed");
}
#[test]
fn decode_line_trailing_lone_eq_sets_pending_escape() {
let encoded = b"*=";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(out, &[0]); assert!(pe, "trailing '=' must set pending_escape");
}
#[test]
fn decode_line_split_escape_across_lines() {
let line1 = b"*=\r\n"; let line2 = b"}\r\n"; let mut out = Vec::new();
let mut pe = false;
decode_line(line1, &mut out, &mut pe);
assert!(pe, "line1 must leave pending_escape set");
decode_line(line2, &mut out, &mut pe);
assert!(!pe, "line2 consumed the pending escape");
assert_eq!(out, &[0u8, 19], "split escape must decode correctly");
}
#[test]
fn decode_line_eq_before_cr_sets_pending_escape() {
let encoded = b"*=\r";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(
out,
&[0],
"=\\r must not produce a garbage byte on this line"
);
assert!(pe, "=\\r must set pending_escape");
}
#[test]
fn decode_line_eq_before_lf_sets_pending_escape() {
let encoded = b"*=\n";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(
out,
&[0],
"=\\n must not produce a garbage byte on this line"
);
assert!(pe, "=\\n must set pending_escape");
}
#[test]
fn decode_line_eq_before_nul_sets_pending_escape() {
let encoded = b"*=\x00";
let mut out = Vec::new();
let mut pe = false;
decode_line(encoded, &mut out, &mut pe);
assert_eq!(
out,
&[0],
"=\\0 must not produce a garbage byte on this line"
);
assert!(pe, "=\\0 must set pending_escape");
}
fn load_fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture {name}: {e}"))
}
#[test]
fn decode_single_part_fixture() {
let input = load_fixture("single_part.yenc");
let part = decode(&input).expect("single_part.yenc should decode cleanly");
assert_eq!(part.data.len(), 64, "decoded length");
assert_eq!(part.data, (0u8..64).collect::<Vec<_>>(), "decoded payload");
assert_eq!(part.metadata.filename, "test.bin");
assert_eq!(part.metadata.size, 64);
assert!(part.crc32_verified, "CRC should be verified");
assert!(part.part.is_none(), "single-part has no part number");
assert!(part.part_begin.is_none());
assert!(part.part_end.is_none());
assert!(part.metadata.total_parts.is_none());
}
#[test]
fn decode_multi_part_1_fixture() {
let input = load_fixture("multi_part_1.yenc");
let part = decode(&input).expect("multi_part_1.yenc should decode");
assert_eq!(part.data, (0u8..64).collect::<Vec<_>>());
assert_eq!(part.metadata.filename, "test.bin");
assert_eq!(part.metadata.size, 128); assert_eq!(part.part, Some(1));
assert_eq!(part.metadata.total_parts, Some(2));
assert_eq!(part.part_begin, Some(1)); assert_eq!(part.part_end, Some(64));
assert!(part.crc32_verified);
}
#[test]
fn decode_multi_part_2_fixture() {
let input = load_fixture("multi_part_2.yenc");
let part = decode(&input).expect("multi_part_2.yenc should decode");
assert_eq!(part.data, (64u8..128).collect::<Vec<_>>());
assert_eq!(part.part, Some(2));
assert_eq!(part.metadata.total_parts, Some(2));
assert_eq!(part.part_begin, Some(65));
assert_eq!(part.part_end, Some(128));
assert!(part.crc32_verified);
}
#[test]
fn decode_prose_preamble_fixture() {
let input = load_fixture("prose_preamble.yenc");
let part = decode(&input).expect("prose_preamble.yenc should decode");
assert_eq!(part.data, (0u8..64).collect::<Vec<_>>());
assert!(part.crc32_verified);
}
#[test]
fn decode_crc_mismatch_fixture() {
let input = load_fixture("crc_mismatch.yenc");
let err = decode(&input).expect_err("crc_mismatch.yenc should return CrcMismatch");
assert!(
matches!(err, YencError::CrcMismatch { expected: 0, .. }),
"expected CrcMismatch with expected=0, got: {:?}",
err
);
}
#[test]
fn decode_truncated_fixture() {
let input = load_fixture("truncated.yenc");
let err = decode(&input).expect_err("truncated.yenc should return UnexpectedEof");
assert_eq!(err, YencError::UnexpectedEof);
}
#[test]
fn decode_empty_input_returns_no_header() {
let err = decode(b"").unwrap_err();
assert_eq!(err, YencError::NoHeader);
}
#[test]
fn decode_missing_name_field_is_error() {
let input = b"=ybegin line=128 size=10\n*+,-\n=yend size=4\n";
let err = decode(input).unwrap_err();
assert!(matches!(err, YencError::InvalidHeader { field } if field == "name"));
}
#[test]
fn decode_missing_size_field_is_error() {
let input = b"=ybegin line=128 name=f.bin\n*+,-\n=yend size=4\n";
let err = decode(input).unwrap_err();
assert!(matches!(err, YencError::InvalidHeader { field } if field == "size"));
}
#[test]
fn decode_missing_yend_is_unexpected_eof() {
let input = b"=ybegin line=128 size=4 name=f.bin\n*+,-\n";
let err = decode(input).unwrap_err();
assert_eq!(err, YencError::UnexpectedEof);
}
#[test]
fn decode_size_mismatch_is_error() {
let input = b"=ybegin line=128 size=10 name=f.bin\n*+,-\n=yend size=10\n";
let err = decode(input).unwrap_err();
assert!(
matches!(
err,
YencError::SizeMismatch {
expected: 10,
actual: 4
}
),
"expected SizeMismatch, got: {:?}",
err
);
}
#[test]
fn decode_no_panic_on_arbitrary_input() {
let inputs: &[&[u8]] = &[
b"",
b"\x00\x01\x02",
b"=ybegin",
b"=ybegin \n",
b"=ybegin size=0 name=f\n=yend size=0 crc32=00000000\n",
b"=ybegin size=1 name=f\n*\n=yend size=1 crc32=00000000\n",
&[b'='; 256],
&[0xFF; 100],
];
for input in inputs {
let _ = decode(input); }
}
#[test]
fn decode_eq_before_crlf_does_not_consume_framing_byte() {
let input = b"=ybegin line=128 size=1 name=test\n*=\r\n=yend size=1 crc32=00000000\n";
let result = crate::decode(input);
if let Ok(decoded) = &result {
assert_eq!(
decoded.data.len(),
1,
"lone '=' before CRLF must not produce extra byte; got {:?}",
decoded.data
);
assert_eq!(decoded.data[0], 0);
}
}
#[test]
fn decode_split_escape_across_lines() {
let input: &[u8] =
b"=ybegin line=1 size=2 name=split.bin\n*\r\n=\r\n}\r\n=yend size=2 crc32=c5675321\n";
let part = decode(input).expect("split-escape article must decode successfully");
assert_eq!(
part.data,
&[0u8, 19],
"split-escape decode must produce correct bytes"
);
assert!(part.crc32_verified, "CRC must verify against oracle");
}
#[test]
fn decode_multipart_crc32_only_no_pcrc32_is_ok() {
let input: &[u8] = b"=ybegin part=1 total=2 line=128 size=128 name=test.bin\n\
=ypart begin=1 end=4\n\
*+,-\n\
=yend size=4 part=1 crc32=deadbeef\n";
let result = decode(input);
assert!(
result.is_ok(),
"expected Ok for multi-part with only crc32= (no pcrc32=), got: {:?}",
result.unwrap_err()
);
let part = result.unwrap();
assert_eq!(part.data, &[0, 1, 2, 3], "decoded bytes");
assert!(
!part.crc32_verified,
"crc32_verified should be false when only whole-file crc32= is present"
);
}
#[test]
fn decode_single_part_ignores_pcrc32_uses_crc32() {
let input: &[u8] = b"=ybegin line=128 size=3 name=test.bin\n\
*+,\n\
=yend size=3 pcrc32=deadbeef crc32=0854897f\n";
let result = decode(input);
assert!(
result.is_ok(),
"expected Ok: single-part must use crc32=, not pcrc32=; got: {:?}",
result.err()
);
let part = result.unwrap();
assert_eq!(part.data, &[0u8, 1, 2]);
assert!(
part.crc32_verified,
"crc32_verified must be true when crc32= is correct"
);
}
}