Files
aria2-rust-pro/crates/aria2-rust-pro-storage/src/control/text.rs
T

316 lines
12 KiB
Rust

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
}