316 lines
12 KiB
Rust
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
|
|
}
|