chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 15:24:15 +08:00
commit e489b29e01
321 changed files with 76890 additions and 0 deletions
@@ -0,0 +1,474 @@
use std::{
fs,
path::{Path, PathBuf},
};
use crate::model::{DownloadFile, PieceIndex, PieceState};
use super::{
model::{ControlFileError, ControlFileVersion, ControlMetadata},
text::{decode_control_metadata, encode_control_metadata},
};
/// Magic trailer marker for text metadata appended to binary control files.
const BINARY_CONTROL_TRAILER_MAGIC: &[u8; 8] = b"AR2RTXT1";
/// Block length used by upstream binary control files for piece bitfields.
const BINARY_PIECE_BLOCK_LENGTH: u64 = 16 * 1024;
/// Writes the simplified control-file representation used by the Rust implementation.
///
/// # Errors
///
/// Returns [`ControlFileError`] when the encoded control file cannot be written.
pub fn write_aria2_control_file(
path: &Path,
metadata: &ControlMetadata,
) -> Result<(), ControlFileError> {
let encoded = encode_binary_control_prefix(metadata)
.and_then(|mut prefix| {
let trailer = encode_binary_control_trailer(metadata)?;
prefix.extend_from_slice(&trailer);
Some(prefix)
})
.unwrap_or_else(|| encode_control_metadata(metadata).into_bytes());
fs::write(path, encoded)?;
Ok(())
}
/// Reads the simplified control-file representation from disk.
///
/// # Errors
///
/// Returns [`ControlFileError`] when the control file cannot be read or decoded.
pub fn read_aria2_control_file(path: &Path) -> Result<ControlMetadata, ControlFileError> {
let content = fs::read(path)?;
if looks_like_binary_control_file(&content) {
return decode_binary_control_metadata(path, &content);
}
let text = String::from_utf8(content)
.map_err(|_| ControlFileError::Parse("control file is not valid UTF-8".to_owned()))?;
decode_control_metadata(&text)
}
/// Reads an upstream-compatible binary `.aria2` control file.
///
/// # Errors
///
/// Returns [`ControlFileError`] when the control file cannot be read or decoded.
pub fn read_aria2_binary_control_file(path: &Path) -> Result<ControlMetadata, ControlFileError> {
let content = fs::read(path)?;
decode_binary_control_metadata(path, &content)
}
/// Detects whether the raw bytes look like an upstream binary control file.
fn looks_like_binary_control_file(content: &[u8]) -> bool {
matches!(
content.get(0..2),
Some(bytes) if bytes == [0x00, 0x00] || bytes == [0x00, 0x01]
)
}
/// Encodes the upstream-compatible binary control-file prefix when possible.
fn encode_binary_control_prefix(metadata: &ControlMetadata) -> Option<Vec<u8>> {
let piece_length = metadata.files.first()?.piece_length;
if piece_length == 0 || piece_length > u64::from(u32::MAX) {
return None;
}
let total_length = metadata.files.iter().map(|file| file.length).sum::<u64>();
let piece_count = binary_piece_count(total_length, piece_length)?;
let mut verified_bitfield = vec![0_u8; binary_bitfield_length(piece_count)?];
let mut normalized_states = std::collections::BTreeMap::<u32, PieceState>::new();
for (index, state) in &metadata.piece_states {
normalized_states.insert(index.0, *state);
}
let mut inflight_pieces = Vec::new();
for (index, state) in normalized_states {
let span_length = control_piece_span_bytes(index, piece_length, total_length);
if span_length > u64::from(u32::MAX) {
return None;
}
match state {
PieceState::Verified => set_binary_bit(&mut verified_bitfield, index),
PieceState::InFlight => inflight_pieces.push((
index,
u32::try_from(span_length).ok()?,
vec![0_u8; binary_bitfield_length(binary_piece_block_count(span_length)?)?],
)),
PieceState::Pending | PieceState::Failed => {}
}
}
let mut bytes = Vec::new();
push_u16_be(&mut bytes, 1_u16);
push_u32_be(&mut bytes, 0_u32);
push_u32_be(&mut bytes, 0_u32);
push_u32_be(&mut bytes, u32::try_from(piece_length).ok()?);
bytes.extend_from_slice(&total_length.to_be_bytes());
bytes.extend_from_slice(&0_u64.to_be_bytes());
push_u32_be(&mut bytes, u32::try_from(verified_bitfield.len()).ok()?);
bytes.extend_from_slice(&verified_bitfield);
push_u32_be(&mut bytes, u32::try_from(inflight_pieces.len()).ok()?);
for (index, length, bitfield) in inflight_pieces {
push_u32_be(&mut bytes, index);
push_u32_be(&mut bytes, length);
push_u32_be(&mut bytes, u32::try_from(bitfield.len()).ok()?);
bytes.extend_from_slice(&bitfield);
}
Some(bytes)
}
/// Encodes a text metadata trailer that can be appended to a binary control file.
fn encode_binary_control_trailer(metadata: &ControlMetadata) -> Option<Vec<u8>> {
let text = encode_control_metadata(metadata);
let text_len = u32::try_from(text.len()).ok()?;
let mut bytes = Vec::new();
bytes.extend_from_slice(BINARY_CONTROL_TRAILER_MAGIC);
push_u32_be(&mut bytes, text_len);
bytes.extend_from_slice(text.as_bytes());
Some(bytes)
}
/// Decodes an upstream-compatible binary control file into normalized metadata.
#[expect(
clippy::too_many_lines,
reason = "binary control metadata decoding keeps the upstream v0/v1 format walk in one auditable parser"
)]
fn decode_binary_control_metadata(
path: &Path,
content: &[u8],
) -> Result<ControlMetadata, ControlFileError> {
if !looks_like_binary_control_file(content) {
return Err(ControlFileError::UnsupportedBinaryCompatibility);
}
let mut offset = 0_usize;
let version = match read_binary_slice(content, &mut offset, 2)? {
[0x00, 0x00] => 0_u16,
[0x00, 0x01] => 1_u16,
_ => return Err(ControlFileError::UnsupportedBinaryCompatibility),
};
let _extension = read_binary_u32(content, &mut offset, version)?;
let info_hash_length = u32_to_usize(read_binary_u32(content, &mut offset, version)?)?;
if info_hash_length > 20 {
return Err(ControlFileError::Parse(
"invalid binary info hash length".to_owned(),
));
}
let _info_hash = read_binary_slice(content, &mut offset, info_hash_length)?;
let piece_length = u64::from(read_binary_u32(content, &mut offset, version)?);
if piece_length == 0 {
return Err(ControlFileError::Parse(
"binary piece length must not be 0".to_owned(),
));
}
let total_length = read_binary_u64(content, &mut offset, version)?;
let _upload_length = read_binary_u64(content, &mut offset, version)?;
let piece_count = binary_piece_count(total_length, piece_length).ok_or_else(|| {
ControlFileError::Parse("binary piece count exceeds supported range".to_owned())
})?;
let bitfield_length = u32_to_usize(read_binary_u32(content, &mut offset, version)?)?;
let expected_bitfield_length = binary_bitfield_length(piece_count).ok_or_else(|| {
ControlFileError::Parse("binary bitfield length exceeds supported range".to_owned())
})?;
if bitfield_length != expected_bitfield_length {
return Err(ControlFileError::Parse(format!(
"binary bitfield length mismatch: expected {expected_bitfield_length}, got {bitfield_length}"
)));
}
let verified_bitfield = read_binary_slice(content, &mut offset, bitfield_length)?;
let mut completed_length = 0_u64;
let mut piece_states = std::collections::BTreeMap::<u32, PieceState>::new();
for index in 0..piece_count {
if binary_bit_is_set(verified_bitfield, index) {
completed_length = completed_length
.checked_add(control_piece_span_bytes(index, piece_length, total_length))
.ok_or_else(|| {
ControlFileError::Parse("binary completed length overflowed".to_owned())
})?;
piece_states.insert(index, PieceState::Verified);
}
}
let inflight_count = read_binary_u32(content, &mut offset, version)?;
for _ in 0..inflight_count {
let index = read_binary_u32(content, &mut offset, version)?;
if total_length > 0 && index >= piece_count {
return Err(ControlFileError::Parse(format!(
"binary in-flight piece index out of range: {index}"
)));
}
let piece_span = control_piece_span_bytes(index, piece_length, total_length);
let encoded_length = u64::from(read_binary_u32(content, &mut offset, version)?);
if encoded_length > piece_span {
return Err(ControlFileError::Parse(format!(
"binary in-flight piece length exceeds span: {encoded_length}"
)));
}
let encoded_bitfield_length =
u32_to_usize(read_binary_u32(content, &mut offset, version)?)?;
let expected_piece_bitfield_length =
binary_bitfield_length(binary_piece_block_count(encoded_length).ok_or_else(|| {
ControlFileError::Parse(
"binary in-flight piece block count exceeds supported range".to_owned(),
)
})?)
.ok_or_else(|| {
ControlFileError::Parse(
"binary in-flight piece bitfield length exceeds supported range".to_owned(),
)
})?;
if encoded_bitfield_length != expected_piece_bitfield_length {
return Err(ControlFileError::Parse(format!(
"binary in-flight piece bitfield length mismatch: expected {expected_piece_bitfield_length}, got {encoded_bitfield_length}"
)));
}
let piece_bitfield = read_binary_slice(content, &mut offset, encoded_bitfield_length)?;
let was_verified = piece_states.get(&index) == Some(&PieceState::Verified);
if !was_verified {
completed_length = completed_length
.checked_add(binary_piece_completed_length(
encoded_length,
piece_bitfield,
))
.ok_or_else(|| {
ControlFileError::Parse("binary completed length overflowed".to_owned())
})?;
}
piece_states.insert(index, PieceState::InFlight);
}
let trailer_slice = content.get(offset..).ok_or_else(|| {
ControlFileError::Parse("binary control trailer offset is out of range".to_owned())
})?;
if let Some(trailer_metadata) = decode_binary_control_trailer(trailer_slice)? {
return Ok(trailer_metadata);
}
Ok(ControlMetadata {
version: if version == 0 {
ControlFileVersion::CURRENT
} else {
ControlFileVersion::BINARY_V1
},
files: vec![DownloadFile {
path: infer_binary_control_target_path(path),
length: total_length,
piece_length,
}],
checksums: Vec::new(),
piece_states: piece_states
.into_iter()
.map(|(index, state)| (PieceIndex(index), state))
.collect(),
completed_length,
retry_count: 0,
last_error: None,
last_error_at_unix_ms: None,
last_retry_at_unix_ms: None,
next_retry_at_unix_ms: None,
consecutive_failure_count: None,
active_segment_count: None,
resume_verified_at_unix_ms: None,
resume_generation: None,
})
}
/// Decodes the optional text trailer appended to a binary control file.
fn decode_binary_control_trailer(
content: &[u8],
) -> Result<Option<ControlMetadata>, ControlFileError> {
if content.is_empty() {
return Ok(None);
}
if !content.starts_with(BINARY_CONTROL_TRAILER_MAGIC) {
return Ok(None);
}
let minimum_length = checked_add_usize(
BINARY_CONTROL_TRAILER_MAGIC.len(),
4,
"binary control trailer",
)?;
if content.len() < minimum_length {
return Err(ControlFileError::Parse(
"binary control trailer is truncated".to_owned(),
));
}
let mut offset = BINARY_CONTROL_TRAILER_MAGIC.len();
let len_end = checked_add_usize(offset, 4, "binary control trailer length field")?;
let len_bytes = content
.get(offset..len_end)
.ok_or_else(|| ControlFileError::Parse("binary control trailer is truncated".to_owned()))?;
let text_len = u32_to_usize(u32::from_be_bytes(len_bytes.try_into().map_err(|_| {
ControlFileError::Parse("binary control trailer is truncated".to_owned())
})?))?;
offset = len_end;
let text_end = checked_add_usize(offset, text_len, "binary control trailer payload")?;
if content.len() != text_end {
return Err(ControlFileError::Parse(
"binary control trailer length mismatch".to_owned(),
));
}
let text_bytes = content
.get(offset..text_end)
.ok_or_else(|| ControlFileError::Parse("binary control trailer is truncated".to_owned()))?;
let text = std::str::from_utf8(text_bytes).map_err(|_| {
ControlFileError::Parse("binary control trailer is not valid UTF-8".to_owned())
})?;
decode_control_metadata(text).map(Some)
}
/// Infers the payload target path from a binary `.aria2` file path.
fn infer_binary_control_target_path(path: &Path) -> PathBuf {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return path.to_path_buf();
};
let Some(stem) = file_name.strip_suffix(".aria2") else {
return path.to_path_buf();
};
path.with_file_name(stem)
}
/// Computes the number of pieces required by a binary control file.
fn binary_piece_count(total_length: u64, piece_length: u64) -> Option<u32> {
if piece_length == 0 {
return Some(0);
}
u32::try_from(total_length.div_ceil(piece_length)).ok()
}
/// Computes the number of sub-blocks represented by a piece bitfield.
fn binary_piece_block_count(piece_length: u64) -> Option<u32> {
if piece_length == 0 {
return Some(0);
}
u32::try_from(piece_length.div_ceil(BINARY_PIECE_BLOCK_LENGTH)).ok()
}
/// Computes the byte length needed to store `bit_count` bits.
fn binary_bitfield_length(bit_count: u32) -> Option<usize> {
usize::try_from(u64::from(bit_count).div_ceil(8)).ok()
}
/// Appends a big-endian `u16` to a binary control buffer.
fn push_u16_be(bytes: &mut Vec<u8>, value: u16) {
bytes.extend_from_slice(&value.to_be_bytes());
}
/// Appends a big-endian `u32` to a binary control buffer.
fn push_u32_be(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_be_bytes());
}
/// Marks one bit inside a binary control-file bitfield.
fn set_binary_bit(bitfield: &mut [u8], index: u32) {
let byte_index = usize::try_from(index >> 3).unwrap_or(usize::MAX);
let bit_offset = 7_u32.saturating_sub(index & 7);
if let Some(byte) = bitfield.get_mut(byte_index) {
*byte |= 1_u8 << bit_offset;
}
}
/// Returns whether one bit is set inside a binary control-file bitfield.
fn binary_bit_is_set(bitfield: &[u8], index: u32) -> bool {
let byte_index = usize::try_from(index >> 3).unwrap_or(usize::MAX);
let bit_offset = 7_u32.saturating_sub(index & 7);
bitfield
.get(byte_index)
.is_some_and(|byte| (byte & (1_u8 << bit_offset)) != 0)
}
/// Computes the completed bytes represented by one in-flight piece bitfield.
fn binary_piece_completed_length(piece_length: u64, bitfield: &[u8]) -> u64 {
let Some(block_count) = binary_piece_block_count(piece_length) else {
return 0;
};
let mut completed = 0_u64;
for block_index in 0..block_count {
if binary_bit_is_set(bitfield, block_index) {
let Some(block_start) = u64::from(block_index).checked_mul(BINARY_PIECE_BLOCK_LENGTH)
else {
return piece_length;
};
let Some(remaining) = piece_length.checked_sub(block_start) else {
return piece_length;
};
completed = completed.saturating_add(remaining.min(BINARY_PIECE_BLOCK_LENGTH));
}
}
completed.min(piece_length)
}
/// Computes the span of one piece within the total binary payload length.
fn control_piece_span_bytes(index: u32, piece_length: u64, total_length: u64) -> u64 {
let Some(start) = u64::from(index).checked_mul(piece_length) else {
return 0;
};
total_length.saturating_sub(start).min(piece_length)
}
/// Reads one raw binary slice and advances the offset.
fn read_binary_slice<'a>(
content: &'a [u8],
offset: &mut usize,
len: usize,
) -> Result<&'a [u8], ControlFileError> {
let end = checked_add_usize(*offset, len, "binary control field")?;
let slice = content
.get(*offset..end)
.ok_or_else(|| ControlFileError::Parse("binary control file is truncated".to_owned()))?;
*offset = end;
Ok(slice)
}
/// Reads a `u32` field using the endianness defined by the binary version.
fn read_binary_u32(
content: &[u8],
offset: &mut usize,
version: u16,
) -> Result<u32, ControlFileError> {
let raw = read_binary_slice(content, offset, 4)?;
let bytes: [u8; 4] = raw
.try_into()
.map_err(|_| ControlFileError::Parse("binary u32 field is truncated".to_owned()))?;
Ok(if version == 0 {
u32::from_le_bytes(bytes)
} else {
u32::from_be_bytes(bytes)
})
}
/// Reads a `u64` field using the endianness defined by the binary version.
fn read_binary_u64(
content: &[u8],
offset: &mut usize,
version: u16,
) -> Result<u64, ControlFileError> {
let raw = read_binary_slice(content, offset, 8)?;
let bytes: [u8; 8] = raw
.try_into()
.map_err(|_| ControlFileError::Parse("binary u64 field is truncated".to_owned()))?;
Ok(if version == 0 {
u64::from_le_bytes(bytes)
} else {
u64::from_be_bytes(bytes)
})
}
/// Converts a `u32` length value into `usize` for buffer indexing.
fn u32_to_usize(value: u32) -> Result<usize, ControlFileError> {
usize::try_from(value).map_err(|_| {
ControlFileError::Parse("binary length exceeds the supported platform size".to_owned())
})
}
/// Adds two `usize` values and returns a parse error on overflow.
fn checked_add_usize(lhs: usize, rhs: usize, context: &str) -> Result<usize, ControlFileError> {
lhs.checked_add(rhs)
.ok_or_else(|| ControlFileError::Parse(format!("{context} length overflowed")))
}
@@ -0,0 +1,124 @@
use std::{fmt, io};
use crate::{
checksum::Checksum,
model::{DownloadFile, PieceIndex, PieceState},
};
/// Version tag for the simplified control-file format.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ControlFileVersion {
/// Major version component.
major: u16,
/// Minor version component.
minor: u16,
}
impl ControlFileVersion {
/// Current text-first control-file version.
pub const CURRENT: Self = Self { major: 1, minor: 0 };
/// Binary-compatible control-file version.
pub const BINARY_V1: Self = Self { major: 1, minor: 1 };
/// Builds a version value from explicit major/minor parts.
#[must_use]
pub(crate) const fn from_parts(major: u16, minor: u16) -> Self {
Self { major, minor }
}
/// Returns the major version component.
#[must_use]
pub const fn major(self) -> u16 {
self.major
}
/// Returns the minor version component.
#[must_use]
pub const fn minor(self) -> u16 {
self.minor
}
}
/// Normalized control metadata stored by the Rust implementation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ControlMetadata {
/// Version of the serialized control-file format.
pub version: ControlFileVersion,
/// Files tracked by the control metadata.
pub files: Vec<DownloadFile>,
/// Whole-download or file-level checksums.
pub checksums: Vec<Checksum>,
/// Non-default piece states captured by the downloader.
pub piece_states: Vec<(PieceIndex, PieceState)>,
/// Number of completed bytes known at serialization time.
pub completed_length: u64,
/// Number of retry attempts already consumed.
pub retry_count: u32,
/// Last runtime error, if one was recorded.
pub last_error: Option<String>,
/// Timestamp for the last runtime error in Unix milliseconds.
pub last_error_at_unix_ms: Option<u64>,
/// Timestamp for the last retry attempt in Unix milliseconds.
pub last_retry_at_unix_ms: Option<u64>,
/// Timestamp for the next scheduled retry in Unix milliseconds.
pub next_retry_at_unix_ms: Option<u64>,
/// Number of consecutive transfer failures, when tracked.
pub consecutive_failure_count: Option<u32>,
/// Number of active download segments, when tracked.
pub active_segment_count: Option<u32>,
/// Timestamp when resume verification last completed.
pub resume_verified_at_unix_ms: Option<u64>,
/// Resume-generation counter used to correlate session state.
pub resume_generation: Option<u64>,
}
/// Text control-file representation preserved for diagnostics.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ControlFileTextModel {
/// Raw control-file lines.
pub lines: Vec<String>,
}
/// Binary control-file representation preserved for diagnostics.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ControlFileBinaryModel {
/// Four-byte binary magic value.
pub magic: [u8; 4],
/// Encoded major version component.
pub version_major: u16,
/// Encoded minor version component.
pub version_minor: u16,
/// Binary payload after the header.
pub payload: Vec<u8>,
}
/// Errors that can occur while reading or writing control files.
#[derive(Debug)]
pub enum ControlFileError {
/// Underlying filesystem I/O error.
Io(io::Error),
/// Malformed or unsupported control-file contents.
Parse(String),
/// Upstream binary format variant is unsupported.
UnsupportedBinaryCompatibility,
}
impl fmt::Display for ControlFileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "io error: {error}"),
Self::Parse(message) => write!(f, "parse error: {message}"),
Self::UnsupportedBinaryCompatibility => {
write!(f, "unsupported binary .aria2 control-file format")
}
}
}
}
impl std::error::Error for ControlFileError {}
impl From<io::Error> for ControlFileError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
@@ -0,0 +1,201 @@
use std::{
collections::BTreeMap,
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
checksum::Checksum,
model::{DownloadFile, PieceIndex, PieceState},
};
use super::*;
fn temp_control_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic enough for test naming")
.as_nanos();
std::env::temp_dir().join(format!("aria2-rust-pro-storage-{name}-{nanos}.aria2"))
}
fn upstream_binary_fixture(version: u16) -> Vec<u8> {
let mut bytes = Vec::new();
match version {
0 => {
bytes.extend_from_slice(&0_u16.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&1024_u32.to_le_bytes());
bytes.extend_from_slice(&81_920_u64.to_le_bytes());
bytes.extend_from_slice(&0_u64.to_le_bytes());
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe]);
bytes.extend_from_slice(&2_u32.to_le_bytes());
bytes.extend_from_slice(&1_u32.to_le_bytes());
bytes.extend_from_slice(&1024_u32.to_le_bytes());
bytes.extend_from_slice(&1_u32.to_le_bytes());
bytes.push(0x00);
bytes.extend_from_slice(&2_u32.to_le_bytes());
bytes.extend_from_slice(&512_u32.to_le_bytes());
bytes.extend_from_slice(&1_u32.to_le_bytes());
bytes.push(0x00);
}
1 => {
bytes.extend_from_slice(&1_u16.to_be_bytes());
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes.extend_from_slice(&1024_u32.to_be_bytes());
bytes.extend_from_slice(&81_920_u64.to_be_bytes());
bytes.extend_from_slice(&0_u64.to_be_bytes());
bytes.extend_from_slice(&10_u32.to_be_bytes());
bytes.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe]);
bytes.extend_from_slice(&2_u32.to_be_bytes());
bytes.extend_from_slice(&1_u32.to_be_bytes());
bytes.extend_from_slice(&1024_u32.to_be_bytes());
bytes.extend_from_slice(&1_u32.to_be_bytes());
bytes.push(0x00);
bytes.extend_from_slice(&2_u32.to_be_bytes());
bytes.extend_from_slice(&512_u32.to_be_bytes());
bytes.extend_from_slice(&1_u32.to_be_bytes());
bytes.push(0x00);
}
other => panic!("unexpected test fixture version: {other}"),
}
bytes
}
fn piece_states_by_index(metadata: &ControlMetadata) -> BTreeMap<u32, PieceState> {
metadata
.piece_states
.iter()
.map(|(index, state)| (index.0, *state))
.collect()
}
#[test]
fn control_metadata_roundtrip_preserves_runtime_fields() {
let metadata = ControlMetadata {
version: ControlFileVersion::CURRENT,
files: vec![DownloadFile {
path: PathBuf::from("D:/downloads/a.bin"),
length: 1024,
piece_length: 256,
}],
checksums: vec![Checksum::Sha256("abc123".to_owned())],
piece_states: vec![
(PieceIndex(0), PieceState::Verified),
(PieceIndex(1), PieceState::InFlight),
],
completed_length: 512,
retry_count: 3,
last_error: Some("timeout".to_owned()),
last_error_at_unix_ms: Some(1_700_000_000_001),
last_retry_at_unix_ms: Some(1_700_000_000_010),
next_retry_at_unix_ms: Some(1_700_000_000_020),
consecutive_failure_count: Some(2),
active_segment_count: Some(4),
resume_verified_at_unix_ms: Some(1_700_000_000_100),
resume_generation: Some(7),
};
let encoded = encode_control_metadata(&metadata);
let decoded = decode_control_metadata(&encoded).unwrap();
assert_eq!(decoded, metadata);
}
#[test]
fn control_metadata_decode_keeps_backward_compat_defaults() {
let raw = "version=1.0\nfiles=0\nchecksums=0\ncompleted_length=12\nretry_count=1\npieces=0";
let decoded = decode_control_metadata(raw).unwrap();
assert_eq!(decoded.completed_length, 12);
assert_eq!(decoded.retry_count, 1);
assert_eq!(decoded.last_error, None);
assert_eq!(decoded.last_error_at_unix_ms, None);
assert_eq!(decoded.active_segment_count, None);
assert_eq!(decoded.resume_generation, None);
}
#[test]
fn binary_control_reader_loads_upstream_v1_fixture() {
let path = temp_control_path("binary-v1-fixture.bin");
fs::write(&path, upstream_binary_fixture(1)).unwrap();
let loaded = read_aria2_binary_control_file(&path).unwrap();
let states = piece_states_by_index(&loaded);
let file = loaded
.files
.first()
.expect("binary control fixture should contain one file");
assert_eq!(loaded.files.len(), 1);
assert_eq!(file.path, path.with_extension(""));
assert_eq!(file.length, 81_920);
assert_eq!(file.piece_length, 1_024);
assert_eq!(loaded.completed_length, 80_896);
assert_eq!(states.get(&0), Some(&PieceState::Verified));
assert_eq!(states.get(&1), Some(&PieceState::InFlight));
assert_eq!(states.get(&2), Some(&PieceState::InFlight));
assert_eq!(states.get(&78), Some(&PieceState::Verified));
assert_eq!(states.get(&79), None);
let _ = fs::remove_file(path);
}
#[test]
fn control_file_reader_auto_detects_upstream_v0_binary_fixture() {
let path = temp_control_path("binary-v0-fixture.bin");
fs::write(&path, upstream_binary_fixture(0)).unwrap();
let loaded = read_aria2_control_file(&path).unwrap();
let states = piece_states_by_index(&loaded);
let file = loaded
.files
.first()
.expect("binary control fixture should contain one file");
assert_eq!(loaded.files.len(), 1);
assert_eq!(file.path, path.with_extension(""));
assert_eq!(file.length, 81_920);
assert_eq!(file.piece_length, 1_024);
assert_eq!(loaded.completed_length, 80_896);
assert_eq!(states.get(&0), Some(&PieceState::Verified));
assert_eq!(states.get(&1), Some(&PieceState::InFlight));
assert_eq!(states.get(&2), Some(&PieceState::InFlight));
let _ = fs::remove_file(path);
}
#[test]
fn control_file_roundtrip_preserves_multiline_runtime_error() {
let metadata = ControlMetadata {
version: ControlFileVersion::CURRENT,
files: vec![DownloadFile {
path: PathBuf::from("D:/downloads/a.bin"),
length: 1024,
piece_length: 256,
}],
checksums: Vec::new(),
piece_states: vec![(PieceIndex(0), PieceState::Verified)],
completed_length: 128,
retry_count: 2,
last_error: Some("timeout\nmirror=2;retry".to_owned()),
last_error_at_unix_ms: Some(1_700_000_001_111),
last_retry_at_unix_ms: Some(1_700_000_001_222),
next_retry_at_unix_ms: Some(1_700_000_001_333),
consecutive_failure_count: Some(4),
active_segment_count: Some(6),
resume_verified_at_unix_ms: Some(1_700_000_001_444),
resume_generation: Some(12),
};
let path = temp_control_path("roundtrip");
write_aria2_control_file(&path, &metadata).unwrap();
let raw = fs::read(&path).unwrap();
assert!(
raw.starts_with(&[0x00, 0x01]),
"control file should start with the upstream binary v0001 header"
);
let loaded = read_aria2_control_file(&path).unwrap();
assert_eq!(loaded, metadata);
let _ = fs::remove_file(path);
}
@@ -0,0 +1,315 @@
use std::path::PathBuf;
use crate::{
checksum::Checksum,
model::{DownloadFile, PieceIndex, PieceState},
};
use super::model::{ControlFileError, ControlFileVersion, ControlMetadata};
/// Encodes normalized metadata into the simplified text control-file format.
#[must_use]
pub fn encode_control_metadata(metadata: &ControlMetadata) -> String {
let mut lines = Vec::new();
lines.push(format!(
"version={}.{}",
metadata.version.major(),
metadata.version.minor()
));
lines.push(format!("files={}", metadata.files.len()));
for file in &metadata.files {
lines.push(format!(
"file={}|{}|{}",
file.path.display(),
file.length,
file.piece_length
));
}
lines.push(format!("checksums={}", metadata.checksums.len()));
for checksum in &metadata.checksums {
lines.push(format!("checksum={}", encode_checksum(checksum)));
}
lines.push(format!("completed_length={}", metadata.completed_length));
lines.push(format!("retry_count={}", metadata.retry_count));
if let Some(value) = &metadata.last_error {
lines.push(format!("last_error={}", escape_control_field(value)));
}
if let Some(value) = metadata.last_error_at_unix_ms {
lines.push(format!("last_error_at_unix_ms={value}"));
}
if let Some(value) = metadata.last_retry_at_unix_ms {
lines.push(format!("last_retry_at_unix_ms={value}"));
}
if let Some(value) = metadata.next_retry_at_unix_ms {
lines.push(format!("next_retry_at_unix_ms={value}"));
}
if let Some(value) = metadata.consecutive_failure_count {
lines.push(format!("consecutive_failure_count={value}"));
}
if let Some(value) = metadata.active_segment_count {
lines.push(format!("active_segment_count={value}"));
}
if let Some(value) = metadata.resume_verified_at_unix_ms {
lines.push(format!("resume_verified_at_unix_ms={value}"));
}
if let Some(value) = metadata.resume_generation {
lines.push(format!("resume_generation={value}"));
}
lines.push(format!("pieces={}", metadata.piece_states.len()));
for (index, state) in &metadata.piece_states {
lines.push(format!("piece={}|{}", index.0, encode_piece_state(*state)));
}
lines.join("\n")
}
/// Decodes the simplified control-file text format.
///
/// # Errors
///
/// Returns [`ControlFileError`] when the encoded text is malformed.
#[expect(
clippy::too_many_lines,
reason = "text control metadata decoding keeps legacy and current field handling in one ordered parser"
)]
pub fn decode_control_metadata(content: &str) -> Result<ControlMetadata, ControlFileError> {
let mut version = ControlFileVersion::CURRENT;
let mut files = Vec::new();
let mut checksums = Vec::new();
let mut piece_states = Vec::new();
let mut completed_length = 0_u64;
let mut retry_count = 0_u32;
let mut last_error = None;
let mut last_error_at_unix_ms = None;
let mut last_retry_at_unix_ms = None;
let mut next_retry_at_unix_ms = None;
let mut consecutive_failure_count = None;
let mut active_segment_count = None;
let mut resume_verified_at_unix_ms = None;
let mut resume_generation = None;
for line in content.lines() {
if let Some(raw) = line.strip_prefix("version=") {
let mut parts = raw.split('.');
let major = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing version major".to_owned()))?
.parse::<u16>()
.map_err(|_| ControlFileError::Parse("invalid version major".to_owned()))?;
let minor = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing version minor".to_owned()))?
.parse::<u16>()
.map_err(|_| ControlFileError::Parse("invalid version minor".to_owned()))?;
version = ControlFileVersion::from_parts(major, minor);
continue;
}
if let Some(raw) = line.strip_prefix("file=") {
let mut parts = raw.split('|');
let path = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing file path".to_owned()))?;
let length = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing file length".to_owned()))?
.parse::<u64>()
.map_err(|_| ControlFileError::Parse("invalid file length".to_owned()))?;
let piece_length = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing file piece_length".to_owned()))?
.parse::<u64>()
.map_err(|_| ControlFileError::Parse("invalid file piece_length".to_owned()))?;
files.push(DownloadFile {
path: PathBuf::from(path),
length,
piece_length,
});
continue;
}
if let Some(raw) = line.strip_prefix("checksum=") {
checksums.push(decode_checksum(raw)?);
continue;
}
if let Some(raw) = line.strip_prefix("completed_length=") {
completed_length = raw
.parse::<u64>()
.map_err(|_| ControlFileError::Parse("invalid completed_length".to_owned()))?;
continue;
}
if let Some(raw) = line.strip_prefix("retry_count=") {
retry_count = raw
.parse::<u32>()
.map_err(|_| ControlFileError::Parse("invalid retry_count".to_owned()))?;
continue;
}
if let Some(raw) = line.strip_prefix("last_error=") {
last_error = Some(unescape_control_field(raw));
continue;
}
if let Some(raw) = line.strip_prefix("last_error_at_unix_ms=") {
last_error_at_unix_ms = Some(raw.parse::<u64>().map_err(|_| {
ControlFileError::Parse("invalid last_error_at_unix_ms".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("last_retry_at_unix_ms=") {
last_retry_at_unix_ms = Some(raw.parse::<u64>().map_err(|_| {
ControlFileError::Parse("invalid last_retry_at_unix_ms".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("next_retry_at_unix_ms=") {
next_retry_at_unix_ms = Some(raw.parse::<u64>().map_err(|_| {
ControlFileError::Parse("invalid next_retry_at_unix_ms".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("consecutive_failure_count=") {
consecutive_failure_count = Some(raw.parse::<u32>().map_err(|_| {
ControlFileError::Parse("invalid consecutive_failure_count".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("active_segment_count=") {
active_segment_count =
Some(raw.parse::<u32>().map_err(|_| {
ControlFileError::Parse("invalid active_segment_count".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("resume_verified_at_unix_ms=") {
resume_verified_at_unix_ms = Some(raw.parse::<u64>().map_err(|_| {
ControlFileError::Parse("invalid resume_verified_at_unix_ms".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("resume_generation=") {
resume_generation =
Some(raw.parse::<u64>().map_err(|_| {
ControlFileError::Parse("invalid resume_generation".to_owned())
})?);
continue;
}
if let Some(raw) = line.strip_prefix("piece=") {
let mut parts = raw.split('|');
let index = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing piece index".to_owned()))?
.parse::<u32>()
.map_err(|_| ControlFileError::Parse("invalid piece index".to_owned()))?;
let state = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing piece state".to_owned()))
.and_then(decode_piece_state)?;
piece_states.push((PieceIndex(index), state));
}
}
Ok(ControlMetadata {
version,
files,
checksums,
piece_states,
completed_length,
retry_count,
last_error,
last_error_at_unix_ms,
last_retry_at_unix_ms,
next_retry_at_unix_ms,
consecutive_failure_count,
active_segment_count,
resume_verified_at_unix_ms,
resume_generation,
})
}
/// Encodes a piece state for the text control-file format.
const fn encode_piece_state(state: PieceState) -> &'static str {
match state {
PieceState::Pending => "pending",
PieceState::InFlight => "in-flight",
PieceState::Verified => "verified",
PieceState::Failed => "failed",
}
}
/// Decodes a piece state from the text control-file format.
fn decode_piece_state(raw: &str) -> Result<PieceState, ControlFileError> {
match raw {
"pending" => Ok(PieceState::Pending),
"in-flight" => Ok(PieceState::InFlight),
"verified" => Ok(PieceState::Verified),
"failed" => Ok(PieceState::Failed),
_ => Err(ControlFileError::Parse("invalid piece state".to_owned())),
}
}
/// Encodes a checksum into the text control-file format.
fn encode_checksum(checksum: &Checksum) -> String {
match checksum {
Checksum::Sha1(value) => format!("sha1:{value}"),
Checksum::Sha256(value) => format!("sha256:{value}"),
Checksum::Md5(value) => format!("md5:{value}"),
Checksum::Adler32(value) => format!("adler32:{value}"),
Checksum::Crc32(value) => format!("crc32:{value}"),
}
}
/// Decodes a checksum from the text control-file format.
fn decode_checksum(raw: &str) -> Result<Checksum, ControlFileError> {
let mut parts = raw.splitn(2, ':');
let algorithm = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing checksum algorithm".to_owned()))?;
let value = parts
.next()
.ok_or_else(|| ControlFileError::Parse("missing checksum value".to_owned()))?
.to_owned();
match algorithm {
"sha1" => Ok(Checksum::Sha1(value)),
"sha256" => Ok(Checksum::Sha256(value)),
"md5" => Ok(Checksum::Md5(value)),
"adler32" => Ok(Checksum::Adler32(value)),
"crc32" => Ok(Checksum::Crc32(value)),
_ => Err(ControlFileError::Parse(
"unsupported checksum algorithm".to_owned(),
)),
}
}
/// Escapes free-form text fields embedded in a control file.
fn escape_control_field(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace(';', "\\s")
.replace(',', "\\c")
.replace('=', "\\e")
}
/// Reverses [`escape_control_field`] for text control-file fields.
fn unescape_control_field(raw: &str) -> String {
let mut out = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('s') => out.push(';'),
Some('c') => out.push(','),
Some('e') => out.push('='),
Some('\\') | None => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(ch);
}
}
out
}