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