chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:16:57 +08:00
commit 17688c3e34
321 changed files with 76859 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "aria2-rust-pro-storage"
version.workspace = true
edition.workspace = true
license.workspace = true
description.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
rust-version.workspace = true
[lib]
name = "aria2_rust_pro_storage"
path = "src/lib.rs"
[lints]
workspace = true
@@ -0,0 +1,47 @@
use std::path::PathBuf;
use crate::model::FileLayout;
/// Declares how files should be materialized before piece writes begin.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AllocationMode {
/// Preserve sparse holes and rely on the filesystem to allocate blocks lazily.
Sparse,
/// Truncate files to their target size without forcing eager block reservation.
Truncate,
/// Request eager allocation for the full file length.
Preallocate,
}
/// Describes the allocation action for a single file in the layout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileAllocation {
/// Absolute or relative target path for the file being allocated.
pub path: PathBuf,
/// Desired final file length in bytes.
pub target_length: u64,
/// Allocation mode to use for this file.
pub mode: AllocationMode,
}
/// Collects all file-allocation steps for a download layout.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PreallocationPlan {
/// Per-file allocation actions in layout order.
pub files: Vec<FileAllocation>,
}
/// Builds a per-file allocation plan for the provided layout.
#[must_use]
pub fn build_preallocation_plan(layout: &FileLayout, mode: AllocationMode) -> PreallocationPlan {
let files = layout
.entries
.iter()
.map(|entry| FileAllocation {
path: entry.path.clone(),
target_length: entry.length,
mode,
})
.collect();
PreallocationPlan { files }
}
@@ -0,0 +1,59 @@
use std::collections::BTreeMap;
use crate::model::PieceIndex;
/// Configures an optional disk cache.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CacheConfig {
/// Maximum total payload bytes the cache should retain.
pub capacity_bytes: u64,
/// Upper bound for a single cached chunk payload.
pub max_entry_bytes: u64,
}
/// Holds one cached payload fragment for a piece span.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CacheEntry {
/// Piece that owns the cached payload.
pub piece: PieceIndex,
/// Offset within the piece where the payload begins.
pub chunk_offset: u64,
/// Cached payload bytes.
pub payload: Vec<u8>,
}
/// Maps piece offsets to entries inside the chunk vector.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ChunkCacheIndex {
/// Lookup table keyed by piece index and piece-relative offset.
pub entries: BTreeMap<(PieceIndex, u64), usize>,
}
/// Simple cache container that keeps payloads and their lookup index together.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DiskCache {
/// Cache configuration, when caching is enabled.
pub config: Option<CacheConfig>,
/// Stored chunk payloads.
pub chunks: Vec<CacheEntry>,
/// Reverse lookup for payload positions.
pub index: ChunkCacheIndex,
}
impl DiskCache {
/// Adds or replaces bookkeeping for a cached chunk payload.
pub fn insert(&mut self, entry: CacheEntry) {
let idx = self.chunks.len();
self.index
.entries
.insert((entry.piece, entry.chunk_offset), idx);
self.chunks.push(entry);
}
/// Returns a cached payload entry for the requested piece span.
#[must_use]
pub fn get(&self, piece: PieceIndex, chunk_offset: u64) -> Option<&CacheEntry> {
let idx = self.index.entries.get(&(piece, chunk_offset))?;
self.chunks.get(*idx)
}
}
@@ -0,0 +1,94 @@
use crate::model::{Piece, PieceIndex};
/// Represents a checksum value together with its algorithm family.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Checksum {
/// SHA-1 checksum encoded as lowercase hexadecimal text.
Sha1(String),
/// SHA-256 checksum encoded as lowercase hexadecimal text.
Sha256(String),
/// MD5 checksum encoded as lowercase hexadecimal text.
Md5(String),
/// Adler-32 checksum encoded as lowercase hexadecimal text.
Adler32(String),
/// CRC-32 checksum encoded as lowercase hexadecimal text.
Crc32(String),
}
/// Identifies a supported hashing algorithm.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HashAlgorithm {
/// SHA-1.
Sha1,
/// SHA-256.
Sha256,
/// MD5.
Md5,
/// Adler-32.
Adler32,
/// CRC-32.
Crc32,
}
/// Stores a finalized digest value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HashDigest {
/// Digest algorithm.
pub algorithm: HashAlgorithm,
/// Digest bytes rendered as hexadecimal text.
pub value_hex: String,
}
/// Reports the result of comparing actual and expected digests.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VerificationResult {
/// The calculated digest matched the expected value.
Match,
/// The calculated digest differed from the expected value.
Mismatch {
/// Digest that was expected by the caller.
expected: HashDigest,
/// Digest that was actually calculated from the payload.
actual: HashDigest,
},
/// The requested algorithm is unsupported by the verifier.
Unsupported(HashAlgorithm),
}
/// Builds algorithm-specific checksum verifiers.
pub trait HasherFactory {
/// Concrete verifier type produced by the factory.
type Hasher: ChecksumVerifier;
/// Creates a verifier for the requested algorithm when supported.
fn create(&self, algorithm: HashAlgorithm) -> Option<Self::Hasher>;
}
/// Incrementally computes a digest for a byte stream.
pub trait ChecksumVerifier {
/// Returns the digest algorithm used by this verifier.
fn algorithm(&self) -> HashAlgorithm;
/// Feeds another payload chunk into the verifier.
fn update(&mut self, chunk: &[u8]);
/// Finalizes and returns the calculated digest.
fn finish(&mut self) -> HashDigest;
}
/// Verifies piece payloads against expected digests.
pub trait PieceHashVerifier {
/// Verifies the supplied piece payload against an expected digest.
fn verify_piece(
&self,
piece: &Piece,
payload: &[u8],
expected: &HashDigest,
) -> VerificationResult;
/// Verifies a piece payload when only the piece index is available.
fn verify_by_index(
&self,
index: PieceIndex,
payload: &[u8],
expected: &HashDigest,
) -> VerificationResult;
}
@@ -0,0 +1,17 @@
/// Binary control-file encoding and decoding helpers.
mod binary;
/// Shared control-file data models and error types.
mod model;
#[cfg(test)]
mod tests;
/// Text control-file encoding and decoding helpers.
mod text;
pub use self::binary::{
read_aria2_binary_control_file, read_aria2_control_file, write_aria2_control_file,
};
pub use self::model::{
ControlFileBinaryModel, ControlFileError, ControlFileTextModel, ControlFileVersion,
ControlMetadata,
};
pub use self::text::{decode_control_metadata, encode_control_metadata};
@@ -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
}
+69
View File
@@ -0,0 +1,69 @@
use std::io;
use crate::model::PieceIndex;
/// References a byte range inside a piece.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ChunkRef {
/// Piece that owns the referenced bytes.
pub piece: PieceIndex,
/// Piece-relative offset where the chunk begins.
pub offset: u64,
/// Chunk length in bytes.
pub length: u64,
}
/// Persists chunk payloads into some backing sink.
pub trait ChunkWriter {
/// Concrete error type returned by the writer.
type Error;
/// Persists a payload segment for the referenced piece span.
///
/// # Errors
///
/// Returns the writer-specific error when the chunk cannot be stored.
fn write_chunk(&mut self, chunk: &ChunkRef, payload: &[u8]) -> Result<(), Self::Error>;
}
/// Adds disk-specific durability operations for chunk writers.
pub trait DiskChunkWriter {
/// Flushes buffered writes to the underlying disk sink.
///
/// # Errors
///
/// Returns an I/O error when buffered state cannot be flushed.
fn flush(&mut self) -> Result<(), io::Error>;
/// Requests that file data is synchronized to stable storage.
///
/// # Errors
///
/// Returns an I/O error when the synchronization request fails.
fn sync_data(&mut self) -> Result<(), io::Error>;
}
/// Reads chunk payloads from durable storage.
pub trait DiskChunkReader {
/// Reads a previously written chunk span from storage.
///
/// # Errors
///
/// Returns an I/O error when the requested chunk cannot be read.
fn read_chunk(&mut self, chunk: &ChunkRef) -> Result<Vec<u8>, io::Error>;
}
/// Test-oriented chunk writer that records all writes in memory.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MemoryChunkWriter {
/// Sequence of chunk writes that were requested.
pub writes: Vec<(ChunkRef, Vec<u8>)>,
}
impl ChunkWriter for MemoryChunkWriter {
type Error = io::Error;
fn write_chunk(&mut self, chunk: &ChunkRef, payload: &[u8]) -> Result<(), Self::Error> {
self.writes.push((*chunk, payload.to_vec()));
Ok(())
}
}
+317
View File
@@ -0,0 +1,317 @@
use std::{
collections::BTreeMap,
fs::{File, OpenOptions},
io::{self, Write},
path::PathBuf,
};
use crate::{disk::ChunkRef, model::PieceIndex};
/// Describes one memory-mapped span for a piece.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MmapIndex {
/// File that contains the mapped bytes.
pub file: PathBuf,
/// Absolute file offset where the mapping begins.
pub offset: u64,
/// Mapped byte length.
pub length: u64,
}
/// Stores all file mappings that belong to each piece.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PieceIndexLookup {
/// Mapping list keyed by piece index.
pub by_piece: BTreeMap<PieceIndex, Vec<MmapIndex>>,
}
/// Writes piece-relative byte ranges into a backing store.
pub trait RangeChunkWriter {
/// Concrete error type returned by the writer.
type Error;
/// Writes a payload range into the mapped piece space.
///
/// # Errors
///
/// Returns the implementation-specific error when the range cannot be written.
fn write_range(
&mut self,
piece: PieceIndex,
piece_offset: u64,
payload: &[u8],
) -> Result<ChunkRef, Self::Error>;
}
/// Reads piece-relative byte ranges from a backing store.
pub trait RangeChunkReader {
/// Concrete error type returned by the reader.
type Error;
/// Reads a payload range from the mapped piece space.
///
/// # Errors
///
/// Returns the implementation-specific error when the range cannot be read.
fn read_range(
&mut self,
piece: PieceIndex,
piece_offset: u64,
len: u64,
) -> Result<Vec<u8>, Self::Error>;
}
/// Appends raw bytes into an output sink.
pub trait ByteSink {
/// Concrete error type returned by the sink.
type Error;
/// Appends bytes into the sink.
///
/// # Errors
///
/// Returns the sink-specific error when the payload cannot be persisted.
fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error>;
}
/// Observed sink that writes to a file and mirrors the bytes in memory.
#[derive(Debug)]
pub struct ObservedFileSink<W = File> {
/// Backing file path.
path: PathBuf,
/// Concrete writer used to persist the payload.
file: W,
/// In-memory observation state.
observed: ObservedByteSink,
}
/// Observed sink that tracks the bytes written through it.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ObservedByteSink {
/// Total number of bytes written through the sink.
observed_len: u64,
/// Retained suffix of the observed byte stream.
retained: Vec<u8>,
/// Optional cap for retained bytes.
retention_limit: Option<usize>,
}
impl ObservedByteSink {
/// Creates an observed sink that retains all bytes.
#[must_use]
pub fn with_unbounded_retention() -> Self {
Self::default()
}
/// Creates an observed sink that retains at most `retention_limit` bytes.
#[must_use]
pub fn with_retention_limit(retention_limit: usize) -> Self {
Self {
retention_limit: Some(retention_limit),
..Self::default()
}
}
/// Returns the total number of bytes observed so far.
#[must_use]
pub const fn observed_len(&self) -> u64 {
self.observed_len
}
/// Returns the retained suffix of the observed byte stream.
#[must_use]
pub const fn retained(&self) -> &[u8] {
self.retained.as_slice()
}
}
impl ObservedFileSink<File> {
/// Creates a file-backed observed sink at the provided path.
///
/// # Errors
///
/// Returns an I/O error when the file cannot be created or truncated.
pub fn create(path: impl Into<PathBuf>) -> Result<Self, io::Error> {
let path = path.into();
let file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)?;
Ok(Self {
path,
file,
observed: ObservedByteSink::with_unbounded_retention(),
})
}
}
impl<W: Write> ObservedFileSink<W> {
/// Returns the backing file path.
#[must_use]
pub const fn path(&self) -> &PathBuf {
&self.path
}
/// Returns the total number of bytes written through the sink.
#[must_use]
pub const fn observed_len(&self) -> u64 {
self.observed.observed_len()
}
/// Returns the retained suffix of the observed byte stream.
#[must_use]
pub const fn retained(&self) -> &[u8] {
self.observed.retained()
}
#[cfg(test)]
fn with_writer(path: impl Into<PathBuf>, file: W) -> Self {
Self {
path: path.into(),
file,
observed: ObservedByteSink::with_unbounded_retention(),
}
}
}
impl<W: Write> ByteSink for ObservedFileSink<W> {
type Error = io::Error;
fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error> {
self.file.write_all(payload)?;
let _ = ByteSink::write(&mut self.observed, payload);
Ok(())
}
}
impl<W: Write> Write for ObservedFileSink<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
<Self as ByteSink>::write(self, buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
impl ByteSink for ObservedByteSink {
type Error = std::convert::Infallible;
fn write(&mut self, payload: &[u8]) -> Result<(), Self::Error> {
let payload_len = u64::try_from(payload.len()).unwrap_or(u64::MAX);
self.observed_len = self.observed_len.saturating_add(payload_len);
self.retained.extend_from_slice(payload);
if let Some(limit) = self.retention_limit
&& self.retained.len() > limit
{
let drop_len = self.retained.len().saturating_sub(limit);
self.retained.drain(..drop_len);
}
Ok(())
}
}
impl Write for ObservedByteSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = <Self as ByteSink>::write(self, buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl PieceIndexLookup {
/// Registers one file mapping for the provided piece.
pub fn add_mapping(&mut self, piece: PieceIndex, mmap_index: MmapIndex) {
self.by_piece.entry(piece).or_default().push(mmap_index);
}
/// Returns all mappings known for the provided piece.
#[must_use]
pub fn mappings(&self, piece: PieceIndex) -> &[MmapIndex] {
self.by_piece.get(&piece).map_or(&[], Vec::as_slice)
}
}
#[cfg(test)]
mod tests {
use std::{fs, io};
use super::{ByteSink, ObservedByteSink, ObservedFileSink};
#[test]
fn observed_sink_tracks_observed_len_and_retains_payload() {
let mut sink = ObservedByteSink::with_unbounded_retention();
sink.write(b"hello").expect("infallible write");
sink.write(b"-world").expect("infallible write");
assert_eq!(sink.observed_len(), 11);
assert_eq!(sink.retained(), b"hello-world");
}
#[test]
fn observed_sink_respects_retention_limit() {
let mut sink = ObservedByteSink::with_retention_limit(4);
sink.write(b"abcdef").expect("infallible write");
assert_eq!(sink.observed_len(), 6);
assert_eq!(sink.retained(), b"cdef");
}
#[test]
fn observed_sink_applies_limit_across_multiple_writes() {
let mut sink = ObservedByteSink::with_retention_limit(5);
sink.write(b"ab").expect("infallible write");
sink.write(b"cde").expect("infallible write");
sink.write(b"fgh").expect("infallible write");
assert_eq!(sink.observed_len(), 8);
assert_eq!(sink.retained(), b"defgh");
}
#[test]
fn observed_file_sink_writes_payload_and_tracks_observation() {
let root = std::env::temp_dir().join("aria2-rust-pro-observed-file-sink-test.bin");
let _ = fs::remove_file(&root);
let mut sink = ObservedFileSink::create(&root).expect("file sink should create");
sink.write(b"abc").expect("file write should work");
sink.write(b"def").expect("file write should work");
assert_eq!(sink.observed_len(), 6);
assert_eq!(sink.retained(), b"abcdef");
assert_eq!(fs::read(&root).expect("file should exist"), b"abcdef");
let _ = fs::remove_file(&root);
}
#[derive(Debug, Default)]
struct FailingWriter;
impl io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::WriteZero,
"synthetic write failure",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn observed_file_sink_propagates_writer_failure_without_advancing_observation() {
let mut sink = ObservedFileSink::with_writer("synthetic.bin", FailingWriter);
let err = ByteSink::write(&mut sink, b"abc").expect_err("write should fail");
assert_eq!(err.kind(), io::ErrorKind::WriteZero);
assert_eq!(sink.observed_len(), 0);
assert!(sink.retained().is_empty());
}
}
+50
View File
@@ -0,0 +1,50 @@
//! Storage-side data models and persistence helpers for `aria2-rust-pro`.
//!
//! This crate keeps the storage-facing contracts small and serializable so the
//! downloader, session, and disk layers can share a stable representation.
#![forbid(unsafe_code)]
/// File allocation planning primitives.
mod allocation;
/// In-memory cache structures for piece payloads.
mod cache;
/// Checksum and verification abstractions.
mod checksum;
/// `.aria2` control-file encoding and decoding.
mod control;
/// Chunk-oriented disk read and write traits.
mod disk;
/// Byte sinks and range-based storage I/O helpers.
mod io;
/// Shared storage-domain models.
mod model;
/// Resume data contracts.
mod resume;
/// Session file encoding and decoding helpers.
mod session;
/// Local filesystem-backed store implementations.
mod store;
pub use allocation::{AllocationMode, FileAllocation, PreallocationPlan, build_preallocation_plan};
pub use cache::{CacheConfig, CacheEntry, ChunkCacheIndex, DiskCache};
pub use checksum::{
Checksum, ChecksumVerifier, HashAlgorithm, HashDigest, HasherFactory, PieceHashVerifier,
VerificationResult,
};
pub use control::{
ControlFileBinaryModel, ControlFileError, ControlFileTextModel, ControlFileVersion,
ControlMetadata, decode_control_metadata, encode_control_metadata,
read_aria2_binary_control_file, read_aria2_control_file, write_aria2_control_file,
};
pub use disk::{ChunkRef, ChunkWriter, DiskChunkReader, DiskChunkWriter, MemoryChunkWriter};
pub use io::{
ByteSink, MmapIndex, ObservedByteSink, ObservedFileSink, PieceIndexLookup, RangeChunkReader,
RangeChunkWriter,
};
pub use model::{DownloadFile, FileEntry, FileLayout, Piece, PieceIndex, PieceMap, PieceState};
pub use resume::{ResumeData, ResumeSnapshot, ResumeStore};
pub use session::{
Aria2MetadataState, SessionFile, SessionFileEntry, load_session_file, save_session_file,
};
pub use store::{ControlStore, LocalFileStore, LocalStoreError, MetadataStore, SessionStore};
+132
View File
@@ -0,0 +1,132 @@
use std::{collections::BTreeMap, path::PathBuf};
/// Identifies a piece by its zero-based index.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PieceIndex(pub u32);
/// Tracks the lifecycle state of a piece.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PieceState {
/// Piece has not been scheduled or verified yet.
Pending,
/// Piece is currently being downloaded or verified.
InFlight,
/// Piece payload has been verified successfully.
Verified,
/// Piece failed to download or verify.
Failed,
}
/// Describes one piece span within a download.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Piece {
/// Zero-based piece identifier.
pub index: PieceIndex,
/// Absolute byte offset where the piece begins.
pub offset: u64,
/// Piece length in bytes.
pub length: u64,
/// Current piece lifecycle state.
pub state: PieceState,
}
/// Describes one file segment inside the logical download layout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileEntry {
/// Zero-based file index inside the layout.
pub index: u32,
/// Final file path.
pub path: PathBuf,
/// Absolute byte offset where the file begins.
pub offset: u64,
/// File length in bytes.
pub length: u64,
}
/// Maps logical download bytes onto output files.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FileLayout {
/// Ordered file entries covering the full download span.
pub entries: Vec<FileEntry>,
/// Total logical download length in bytes.
pub total_length: u64,
/// Nominal piece length in bytes.
pub piece_length: u64,
}
impl FileLayout {
/// Returns the number of pieces needed to cover the layout.
///
/// When the logical piece count exceeds `u32::MAX`, the value saturates at
/// `u32::MAX`.
#[must_use]
pub fn piece_count(&self) -> u32 {
if self.piece_length == 0 {
return 0;
}
u32::try_from(self.total_length.div_ceil(self.piece_length)).unwrap_or(u32::MAX)
}
}
/// Tracks non-default piece states by piece index.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PieceMap {
/// Sparse map of explicitly stored piece states.
states: BTreeMap<PieceIndex, PieceState>,
}
impl PieceMap {
/// Returns the current state for the provided piece index.
#[must_use]
pub fn state(&self, index: PieceIndex) -> PieceState {
self.states
.get(&index)
.copied()
.unwrap_or(PieceState::Pending)
}
/// Stores the state for a piece index.
pub fn set_state(&mut self, index: PieceIndex, state: PieceState) {
self.states.insert(index, state);
}
/// Iterates over piece states that have been explicitly stored.
pub fn iter(&self) -> impl Iterator<Item = (&PieceIndex, &PieceState)> {
self.states.iter()
}
/// Sums the verified bytes represented by the current piece map.
#[must_use]
pub fn completed_verified_bytes(&self, piece_length: u64, total_length: u64) -> u64 {
self.states
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.map(|(index, _)| piece_span_bytes(index.0, piece_length, total_length))
.sum()
}
}
/// Normalized file information used by control metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadFile {
/// Final output path for the file.
pub path: PathBuf,
/// File length in bytes.
pub length: u64,
/// Piece length used by the containing download.
pub piece_length: u64,
}
/// Returns the byte length covered by a piece index.
fn 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;
};
if start >= total_length {
return 0;
}
let Some(remaining) = total_length.checked_sub(start) else {
return 0;
};
remaining.min(piece_length)
}
@@ -0,0 +1,81 @@
use std::path::PathBuf;
use crate::{
control::ControlMetadata,
model::{PieceMap, PieceState},
};
/// Persisted resume payload for a single download.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResumeData {
/// Download gid that owns this resume state.
pub gid: String,
/// Final download path tracked by the resumer.
pub download_path: PathBuf,
/// Decoded control metadata, when available.
pub metadata: Option<ControlMetadata>,
/// Piece-state map captured for the download.
pub piece_map: PieceMap,
}
/// Flattened summary of resumable piece groups.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResumeSnapshot {
/// Download gid that owns this snapshot.
pub gid: String,
/// Piece indexes that are fully verified.
pub verified_pieces: Vec<u32>,
/// Piece indexes that are currently in flight.
pub inflight_pieces: Vec<u32>,
/// Piece indexes that failed verification or transfer.
pub failed_pieces: Vec<u32>,
}
/// Persists and retrieves resume state for downloads.
pub trait ResumeStore {
/// Concrete error type returned by the store implementation.
type Error;
/// Loads any persisted resume state for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when persisted resume state cannot be read.
fn load(&self, gid: &str) -> Result<Option<ResumeData>, Self::Error>;
/// Persists resume state for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when resume state cannot be written.
fn save(&self, resume: &ResumeData) -> Result<(), Self::Error>;
/// Removes persisted resume state for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when the persisted resume state cannot be removed.
fn remove(&self, gid: &str) -> Result<(), Self::Error>;
}
impl ResumeSnapshot {
/// Builds a flattened snapshot from the full resume payload.
#[must_use]
pub fn from_resume_data(data: &ResumeData) -> Self {
let mut verified_pieces = Vec::new();
let mut inflight_pieces = Vec::new();
let mut failed_pieces = Vec::new();
for (index, state) in data.piece_map.iter() {
match state {
PieceState::Verified => verified_pieces.push(index.0),
PieceState::InFlight => inflight_pieces.push(index.0),
PieceState::Failed => failed_pieces.push(index.0),
PieceState::Pending => {}
}
}
Self {
gid: data.gid.clone(),
verified_pieces,
inflight_pieces,
failed_pieces,
}
}
}
@@ -0,0 +1,330 @@
use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
/// One download entry inside a session file.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionFileEntry {
/// Download gid.
pub gid: String,
/// Primary download URI.
pub uri: String,
/// All known mirror URIs for the download.
pub uris: Vec<String>,
/// Target output path.
pub target_path: PathBuf,
/// Optional path to sidecar metadata.
pub metadata_path: Option<PathBuf>,
/// Additional key-value metadata preserved by the session file.
pub metadata: Option<BTreeMap<String, String>>,
}
/// Serialized session file contents.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SessionFile {
/// Download entries stored in the session file.
pub entries: Vec<SessionFileEntry>,
}
/// Metadata key-value state tracked for one gid.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Aria2MetadataState {
/// Download gid.
pub gid: String,
/// Metadata key-value pairs.
pub kv: BTreeMap<String, String>,
}
/// Writes the simplified session file format used by the Rust implementation.
///
/// # Errors
///
/// Returns an I/O error when the session file cannot be written.
pub fn save_session_file(path: &Path, session: &SessionFile) -> Result<(), io::Error> {
let mut lines = Vec::new();
for entry in &session.entries {
let uris = if entry.uris.is_empty() {
vec![entry.uri.clone()]
} else {
entry.uris.clone()
};
let metadata_kv = entry
.metadata
.as_ref()
.map(|kv| {
kv.iter()
.map(|(k, v)| format!("{}={}", escape_field(k), escape_field(v)))
.collect::<Vec<_>>()
.join(";")
})
.unwrap_or_default();
lines.push(format!(
"v2\t{}\t{}\t{}\t{}\t{}",
escape_field(&entry.gid),
uris.iter()
.map(|uri| escape_field(uri))
.collect::<Vec<_>>()
.join(","),
escape_field(entry.target_path.to_string_lossy().as_ref()),
entry
.metadata_path
.as_ref()
.map(|p| escape_field(p.to_string_lossy().as_ref()))
.unwrap_or_default(),
metadata_kv
));
}
fs::write(path, lines.join("\n"))
}
/// Loads a simplified session file from disk.
///
/// # Errors
///
/// Returns an I/O error when the session file cannot be read.
pub fn load_session_file(path: &Path) -> Result<SessionFile, io::Error> {
let content = fs::read_to_string(path)?;
let mut entries = Vec::new();
for line in content.lines().filter(|line| !line.trim().is_empty()) {
if let Some(payload) = line.strip_prefix("v2\t") {
let mut parts = payload.splitn(5, '\t');
let gid = unescape_field(parts.next().unwrap_or_default());
let uris_raw = parts.next().unwrap_or_default();
let uris = if uris_raw.is_empty() {
Vec::new()
} else {
uris_raw.split(',').map(unescape_field).collect::<Vec<_>>()
};
let uri = uris.first().cloned().unwrap_or_default();
let target_path = PathBuf::from(unescape_field(parts.next().unwrap_or_default()));
let metadata_path = match parts.next() {
Some(raw) if !raw.is_empty() => Some(PathBuf::from(unescape_field(raw))),
_ => None,
};
let metadata = parse_metadata_map(parts.next().unwrap_or_default());
entries.push(SessionFileEntry {
gid,
uri,
uris,
target_path,
metadata_path,
metadata,
});
continue;
}
let mut parts = line.splitn(4, '\t');
let gid = parts.next().unwrap_or_default().to_owned();
let uri = parts.next().unwrap_or_default().to_owned();
let target_path = PathBuf::from(parts.next().unwrap_or_default());
let metadata_path = match parts.next() {
Some(raw) if !raw.is_empty() => Some(PathBuf::from(raw)),
_ => None,
};
entries.push(SessionFileEntry {
gid,
uri: uri.clone(),
uris: if uri.is_empty() {
Vec::new()
} else {
vec![uri]
},
target_path,
metadata_path,
metadata: None,
});
}
Ok(SessionFile { entries })
}
/// Parses the serialized metadata map payload from a session entry.
fn parse_metadata_map(raw: &str) -> Option<BTreeMap<String, String>> {
if raw.is_empty() {
return None;
}
let mut out = BTreeMap::new();
for pair in raw.split(';') {
if pair.is_empty() {
continue;
}
let mut parts = pair.splitn(2, '=');
let key = unescape_field(parts.next().unwrap_or_default());
let value = unescape_field(parts.next().unwrap_or_default());
out.insert(key, value);
}
Some(out)
}
/// Escapes field separators used by the session file format.
fn escape_field(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace(';', "\\s")
.replace(',', "\\c")
.replace('=', "\\e")
}
/// Reverses [`escape_field`] for a serialized session field.
fn unescape_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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_roundtrip_preserves_metadata_extensions() {
let mut metadata = BTreeMap::new();
metadata.insert("etag".to_owned(), "abc=123".to_owned());
metadata.insert(
"aria2.resume_path".to_owned(),
"D:/downloads/file.resume".to_owned(),
);
metadata.insert("aria2.segment_count_hint".to_owned(), "16".to_owned());
metadata.insert("aria2.resume_generation".to_owned(), "9".to_owned());
metadata.insert(
"aria2.last_runtime_error".to_owned(),
"timeout on mirror #2".to_owned(),
);
let session = SessionFile {
entries: vec![SessionFileEntry {
gid: "gid-1".to_owned(),
uri: "https://a.example/file".to_owned(),
uris: vec![
"https://a.example/file".to_owned(),
"https://b.example/file".to_owned(),
],
target_path: PathBuf::from("D:/downloads/file.bin"),
metadata_path: Some(PathBuf::from("D:/downloads/file.meta")),
metadata: Some(metadata),
}],
};
let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-roundtrip.txt");
save_session_file(&path, &session).unwrap();
let loaded = load_session_file(&path).unwrap();
assert_eq!(loaded, session);
let _ = fs::remove_file(path);
}
#[test]
fn session_v2_decode_defaults_new_fields() {
let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-compat.txt");
fs::write(
&path,
"v2\tgid-2\thttps://a.example/file\tD:/downloads/file.bin\t\t",
)
.unwrap();
let loaded = load_session_file(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
let entry = loaded
.entries
.first()
.expect("single v2 entry should be present");
assert_eq!(entry.gid, "gid-2");
let _ = fs::remove_file(path);
}
#[test]
fn session_roundtrip_with_multiple_uris_and_escaped_metadata() {
let mut metadata = BTreeMap::new();
metadata.insert(
"meta;key=1".to_owned(),
"line1\nline2\twith\\slash,semi;eq=".to_owned(),
);
metadata.insert("plain".to_owned(), "value".to_owned());
let session = SessionFile {
entries: vec![SessionFileEntry {
gid: "gid-escaped".to_owned(),
uri: "https://a.example/file?x=1,y=2".to_owned(),
uris: vec![
"https://a.example/file?x=1,y=2".to_owned(),
"https://b.example/file;alt=1".to_owned(),
"https://c.example/file\\mirror".to_owned(),
],
target_path: PathBuf::from("D:/downloads/escaped-file.bin"),
metadata_path: Some(PathBuf::from("D:/downloads/escaped-file.meta")),
metadata: Some(metadata),
}],
};
let path = std::env::temp_dir().join("aria2-rust-pro-session-v2-escaped-roundtrip.txt");
save_session_file(&path, &session).unwrap();
let loaded = load_session_file(&path).unwrap();
assert_eq!(loaded, session);
let _ = fs::remove_file(path);
}
#[test]
fn load_session_file_supports_mixed_legacy_and_v2_lines() {
let path = std::env::temp_dir().join("aria2-rust-pro-session-mixed-compat.txt");
let mixed = concat!(
"legacy-gid\thttps://legacy.example/file\tD:/downloads/legacy.bin\t\n",
"v2\tgid-v2\thttps://a.example/file,https://b.example/file\\cwith-comma\tD:/downloads/v2.bin\tD:/downloads/v2.meta\tk\\e1=v\\s1\n"
);
fs::write(&path, mixed).unwrap();
let loaded = load_session_file(&path).unwrap();
assert_eq!(loaded.entries.len(), 2);
let legacy = loaded
.entries
.first()
.expect("legacy entry should be present");
assert_eq!(legacy.gid, "legacy-gid");
assert_eq!(legacy.uri, "https://legacy.example/file");
assert_eq!(legacy.uris, vec!["https://legacy.example/file".to_owned()]);
assert_eq!(legacy.target_path, PathBuf::from("D:/downloads/legacy.bin"));
assert_eq!(legacy.metadata_path, None);
assert_eq!(legacy.metadata, None);
let v2 = loaded.entries.get(1).expect("v2 entry should be present");
assert_eq!(v2.gid, "gid-v2");
assert_eq!(
v2.uris,
vec![
"https://a.example/file".to_owned(),
"https://b.example/file,with-comma".to_owned(),
]
);
assert_eq!(v2.uri, "https://a.example/file");
assert_eq!(v2.target_path, PathBuf::from("D:/downloads/v2.bin"));
assert_eq!(
v2.metadata_path,
Some(PathBuf::from("D:/downloads/v2.meta"))
);
assert_eq!(
v2.metadata
.as_ref()
.and_then(|kv| kv.get("k=1"))
.map(String::as_str),
Some("v;1")
);
let _ = fs::remove_file(path);
}
}
+427
View File
@@ -0,0 +1,427 @@
use std::{fs, io, path::PathBuf};
use crate::{
control::{
ControlMetadata, decode_control_metadata, encode_control_metadata, read_aria2_control_file,
write_aria2_control_file,
},
model::{PieceIndex, PieceMap, PieceState},
resume::{ResumeData, ResumeStore},
session::{Aria2MetadataState, SessionFile, load_session_file, save_session_file},
};
/// Persists control metadata by download gid.
pub trait ControlStore {
/// Concrete error type returned by the store implementation.
type Error;
/// Loads persisted control metadata for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when control metadata cannot be read.
fn load_control(&self, gid: &str) -> Result<Option<ControlMetadata>, Self::Error>;
/// Persists control metadata for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when control metadata cannot be written.
fn save_control(&self, gid: &str, value: &ControlMetadata) -> Result<(), Self::Error>;
/// Deletes control metadata for a download gid.
///
/// # Errors
///
/// Returns the store-specific error when control metadata cannot be removed.
fn delete_control(&self, gid: &str) -> Result<(), Self::Error>;
}
/// Persists the session file.
pub trait SessionStore {
/// Concrete error type returned by the store implementation.
type Error;
/// Loads the persisted session file.
///
/// # Errors
///
/// Returns the store-specific error when the session file cannot be read.
fn load_session(&self) -> Result<SessionFile, Self::Error>;
/// Persists the session file.
///
/// # Errors
///
/// Returns the store-specific error when the session file cannot be written.
fn save_session(&self, session: &SessionFile) -> Result<(), Self::Error>;
}
/// Persists metadata and resume state keyed by gid.
pub trait MetadataStore {
/// Concrete error type returned by the store implementation.
type Error;
/// Loads persisted metadata state for a gid.
///
/// # Errors
///
/// Returns the store-specific error when metadata state cannot be read.
fn load_metadata_state(&self, gid: &str) -> Result<Option<Aria2MetadataState>, Self::Error>;
/// Persists metadata state for a gid.
///
/// # Errors
///
/// Returns the store-specific error when metadata state cannot be written.
fn save_metadata_state(&self, state: &Aria2MetadataState) -> Result<(), Self::Error>;
/// Persists resume data through the metadata-oriented store surface.
///
/// # Errors
///
/// Returns the store-specific error when resume data cannot be written.
fn save_resume_data(&self, resume: &ResumeData) -> Result<(), Self::Error>;
}
/// Error type used by the local filesystem-backed store.
#[derive(Debug)]
pub enum LocalStoreError {
/// Raw filesystem I/O failure.
Io(io::Error),
/// Format or decoding failure.
Parse(String),
}
impl From<io::Error> for LocalStoreError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
/// Local filesystem-backed implementation of the storage traits.
#[derive(Debug)]
pub struct LocalFileStore {
/// Root directory that contains the store layout.
root: PathBuf,
}
impl LocalFileStore {
/// Creates a store rooted at the provided directory.
#[must_use]
pub const fn new(root: PathBuf) -> Self {
Self { root }
}
/// Creates the on-disk directory layout used by the local store.
///
/// # Errors
///
/// Returns [`LocalStoreError`] when any required directory cannot be created.
pub fn ensure_layout(&self) -> Result<(), LocalStoreError> {
fs::create_dir_all(self.controls_dir())?;
fs::create_dir_all(self.metadata_dir())?;
fs::create_dir_all(self.resume_dir())?;
if let Some(parent) = self.session_path().parent() {
fs::create_dir_all(parent)?;
}
Ok(())
}
/// Returns the control-file directory.
fn controls_dir(&self) -> PathBuf {
self.root.join("control")
}
/// Returns the metadata directory.
fn metadata_dir(&self) -> PathBuf {
self.root.join("metadata")
}
/// Returns the resume directory.
fn resume_dir(&self) -> PathBuf {
self.root.join("resume")
}
/// Returns the persisted session file path.
fn session_path(&self) -> PathBuf {
self.root.join("session").join("session.txt")
}
/// Returns the control-file path for one gid.
fn control_path(&self, gid: &str) -> PathBuf {
self.controls_dir().join(format!("{gid}.aria2"))
}
/// Returns the metadata path for one gid.
fn metadata_path(&self, gid: &str) -> PathBuf {
self.metadata_dir().join(format!("{gid}.meta"))
}
/// Returns the resume-file path for one gid.
fn resume_path(&self, gid: &str) -> PathBuf {
self.resume_dir().join(format!("{gid}.resume"))
}
}
impl ControlStore for LocalFileStore {
type Error = LocalStoreError;
fn load_control(&self, gid: &str) -> Result<Option<ControlMetadata>, Self::Error> {
let path = self.control_path(gid);
if !path.exists() {
return Ok(None);
}
read_aria2_control_file(&path)
.map(Some)
.map_err(|e| LocalStoreError::Parse(e.to_string()))
}
fn save_control(&self, gid: &str, value: &ControlMetadata) -> Result<(), Self::Error> {
self.ensure_layout()?;
write_aria2_control_file(&self.control_path(gid), value)
.map_err(|e| LocalStoreError::Parse(e.to_string()))
}
fn delete_control(&self, gid: &str) -> Result<(), Self::Error> {
let path = self.control_path(gid);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
}
impl SessionStore for LocalFileStore {
type Error = LocalStoreError;
fn load_session(&self) -> Result<SessionFile, Self::Error> {
let path = self.session_path();
if !path.exists() {
return Ok(SessionFile::default());
}
load_session_file(&path).map_err(LocalStoreError::Io)
}
fn save_session(&self, session: &SessionFile) -> Result<(), Self::Error> {
self.ensure_layout()?;
save_session_file(&self.session_path(), session).map_err(LocalStoreError::Io)
}
}
impl MetadataStore for LocalFileStore {
type Error = LocalStoreError;
fn load_metadata_state(&self, gid: &str) -> Result<Option<Aria2MetadataState>, Self::Error> {
let path = self.metadata_path(gid);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
let mut kv = std::collections::BTreeMap::new();
for line in content.lines() {
let mut parts = line.splitn(2, '=');
let k = parts.next().unwrap_or_default();
let v = parts.next().unwrap_or_default();
if !k.is_empty() {
kv.insert(k.to_owned(), v.to_owned());
}
}
Ok(Some(Aria2MetadataState {
gid: gid.to_owned(),
kv,
}))
}
fn save_metadata_state(&self, state: &Aria2MetadataState) -> Result<(), Self::Error> {
self.ensure_layout()?;
let body = state
.kv
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\n");
fs::write(self.metadata_path(&state.gid), body)?;
Ok(())
}
fn save_resume_data(&self, resume: &ResumeData) -> Result<(), Self::Error> {
<Self as ResumeStore>::save(self, resume)
}
}
impl ResumeStore for LocalFileStore {
type Error = LocalStoreError;
fn load(&self, gid: &str) -> Result<Option<ResumeData>, Self::Error> {
let path = self.resume_path(gid);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
let mut download_path = None;
let mut verified = Vec::new();
let mut inflight = Vec::new();
let mut failed = Vec::new();
let mut metadata_blob = String::new();
let mut in_metadata = false;
for line in content.lines() {
if line == "metadata<<" {
in_metadata = true;
continue;
}
if line == ">>metadata" {
in_metadata = false;
continue;
}
if in_metadata {
metadata_blob.push_str(line);
metadata_blob.push('\n');
continue;
}
if let Some(v) = line.strip_prefix("download_path=") {
download_path = Some(PathBuf::from(v));
}
if let Some(v) = line.strip_prefix("verified=") {
verified = parse_csv_u32(v)?;
}
if let Some(v) = line.strip_prefix("inflight=") {
inflight = parse_csv_u32(v)?;
}
if let Some(v) = line.strip_prefix("failed=") {
failed = parse_csv_u32(v)?;
}
}
let mut piece_map = PieceMap::default();
for idx in verified {
piece_map.set_state(PieceIndex(idx), PieceState::Verified);
}
for idx in inflight {
piece_map.set_state(PieceIndex(idx), PieceState::InFlight);
}
for idx in failed {
piece_map.set_state(PieceIndex(idx), PieceState::Failed);
}
let metadata = if metadata_blob.trim().is_empty() {
None
} else {
Some(
decode_control_metadata(metadata_blob.trim_end())
.map_err(|e| LocalStoreError::Parse(e.to_string()))?,
)
};
Ok(Some(ResumeData {
gid: gid.to_owned(),
download_path: download_path.unwrap_or_default(),
metadata,
piece_map,
}))
}
fn save(&self, resume: &ResumeData) -> Result<(), Self::Error> {
self.ensure_layout()?;
let mut lines = Vec::new();
lines.push(format!(
"download_path={}",
resume.download_path.to_string_lossy()
));
lines.push(format!(
"verified={}",
join_piece_indexes(&resume.piece_map, PieceState::Verified)
));
lines.push(format!(
"inflight={}",
join_piece_indexes(&resume.piece_map, PieceState::InFlight)
));
lines.push(format!(
"failed={}",
join_piece_indexes(&resume.piece_map, PieceState::Failed)
));
if let Some(metadata) = &resume.metadata {
lines.push("metadata<<".to_owned());
lines.push(encode_control_metadata(metadata));
lines.push(">>metadata".to_owned());
}
fs::write(self.resume_path(&resume.gid), lines.join("\n"))?;
Ok(())
}
fn remove(&self, gid: &str) -> Result<(), Self::Error> {
let path = self.resume_path(gid);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
}
/// Parses a comma-separated list of piece indexes.
fn parse_csv_u32(raw: &str) -> Result<Vec<u32>, LocalStoreError> {
if raw.trim().is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|s| {
s.parse::<u32>()
.map_err(|_| LocalStoreError::Parse(format!("invalid piece index: {s}")))
})
.collect()
}
/// Joins piece indexes in the provided state into a comma-separated string.
fn join_piece_indexes(piece_map: &PieceMap, target: PieceState) -> String {
piece_map
.iter()
.filter(|(_, state)| **state == target)
.map(|(idx, _)| idx.0.to_string())
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
use super::*;
use crate::session::SessionFileEntry;
fn temp_root() -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
std::env::temp_dir().join(format!("aria2-rust-pro-storage-test-{millis}"))
}
#[test]
fn local_store_session_roundtrip() {
let root = temp_root();
let store = LocalFileStore::new(root.clone());
let mut metadata = BTreeMap::new();
metadata.insert("bt.name".to_owned(), "ubuntu".to_owned());
let session = SessionFile {
entries: vec![SessionFileEntry {
gid: "gid-1".to_owned(),
uri: "https://mirror-1.example/file.iso".to_owned(),
uris: vec![
"https://mirror-1.example/file.iso".to_owned(),
"https://mirror-2.example/file.iso".to_owned(),
],
target_path: PathBuf::from("D:/downloads/file.iso"),
metadata_path: Some(PathBuf::from("D:/downloads/file.meta")),
metadata: Some(metadata),
}],
};
store.save_session(&session).unwrap();
let loaded = store.load_session().unwrap();
assert_eq!(loaded, session);
let _ = fs::remove_dir_all(root);
}
#[test]
fn local_store_resume_roundtrip() {
let root = temp_root();
let store = LocalFileStore::new(root.clone());
let mut piece_map = PieceMap::default();
piece_map.set_state(PieceIndex(0), PieceState::Verified);
piece_map.set_state(PieceIndex(1), PieceState::InFlight);
piece_map.set_state(PieceIndex(2), PieceState::Failed);
let resume = ResumeData {
gid: "gid-resume-1".to_owned(),
download_path: PathBuf::from("D:/downloads/file.iso"),
metadata: None,
piece_map,
};
store.save(&resume).unwrap();
let loaded = store.load(&resume.gid).unwrap().unwrap();
assert_eq!(loaded, resume);
let _ = fs::remove_dir_all(root);
}
}