chore: initial sanitized public snapshot
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user