use std::path::{Component, Path, PathBuf};
use crate::error::{Par2Error, Result};
fn encode_byte(byte: u8, out: &mut String) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0F) as usize] as char);
}
fn encode_char(ch: char, out: &mut String) {
let mut bytes = [0u8; 4];
for byte in ch.encode_utf8(&mut bytes).as_bytes() {
encode_byte(*byte, out);
}
}
fn encode_component(component: &str) -> String {
let mut encoded = String::with_capacity(component.len());
for ch in component.chars() {
if is_illegal_local_char(ch) {
encode_char(ch, &mut encoded);
} else {
encoded.push(ch);
}
}
encoded
}
#[cfg(windows)]
fn is_illegal_local_char(ch: char) -> bool {
ch.is_control() || matches!(ch, '"' | '*' | ':' | '<' | '>' | '?' | '|' | '\0')
}
#[cfg(not(windows))]
fn is_illegal_local_char(ch: char) -> bool {
ch.is_control()
}
pub fn translate_par2_name_to_relative(par2_name: &str) -> Result<String> {
let normalized = par2_name.replace('\\', "/");
let mut parts = Vec::new();
let mut saw_name_component = false;
for (index, part) in normalized.split('/').enumerate() {
if part.is_empty() {
if index == 0 && normalized.starts_with('/') {
parts.push("%2F".to_string());
saw_name_component = true;
}
continue;
}
saw_name_component = true;
let encoded = match part {
".." => "%2E%2E".to_string(),
other => encode_component(other),
};
if !encoded.is_empty() {
parts.push(encoded);
}
}
if !saw_name_component {
return Err(Par2Error::InvalidFileDescPacket {
reason: "filename is empty after translation".to_string(),
});
}
Ok(parts.join("/"))
}
pub fn translate_par2_name_to_local_path(par2_name: &str, base_dir: &Path) -> Result<PathBuf> {
let relative = translate_par2_name_to_relative(par2_name)?;
let relative_path = Path::new(&relative);
if relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(Par2Error::InvalidFileDescPacket {
reason: format!("translated filename is not relative: {relative}"),
});
}
Ok(base_dir.join(relative_path))
}
pub(crate) fn is_generated_par2_artifact_name(name: &str) -> bool {
name.starts_with(".swap.")
|| name.starts_with(".chunk.")
|| name.starts_with(".weaver-par2-repair")
|| name.contains(".weaver-par2-backup.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preserves_safe_subdirectories() {
assert_eq!(
translate_par2_name_to_relative("season/episode.rar").unwrap(),
"season/episode.rar"
);
assert_eq!(
translate_par2_name_to_relative("season/./episode.rar").unwrap(),
"season/./episode.rar"
);
}
#[test]
fn normalizes_windows_separators() {
assert_eq!(
translate_par2_name_to_relative("season\\episode.rar").unwrap(),
"season/episode.rar"
);
}
#[test]
fn encodes_absolute_and_parent_paths() {
assert_eq!(
translate_par2_name_to_relative("/tmp/../episode.rar").unwrap(),
"%2F/tmp/%2E%2E/episode.rar"
);
assert_eq!(translate_par2_name_to_relative("/").unwrap(), "%2F");
}
#[test]
#[cfg(not(windows))]
fn preserves_unix_legal_punctuation() {
assert_eq!(
translate_par2_name_to_relative("bad:name*.rar").unwrap(),
"bad:name*.rar"
);
}
#[test]
#[cfg(windows)]
fn encodes_windows_illegal_punctuation() {
assert_eq!(
translate_par2_name_to_relative("bad:name*.rar").unwrap(),
"bad%3Aname%2A.rar"
);
}
#[test]
fn encodes_control_and_platform_illegal_chars() {
assert_eq!(
translate_par2_name_to_relative("bad:name\u{1}.rar").unwrap(),
format!("bad{}name%01.rar", if cfg!(windows) { "%3A" } else { ":" })
);
}
#[test]
fn rejects_empty_names() {
assert!(translate_par2_name_to_relative("").is_err());
}
#[test]
fn identifies_generated_repair_artifacts() {
assert!(is_generated_par2_artifact_name(".swap.movie.rar.tmp"));
assert!(is_generated_par2_artifact_name(".chunk.movie.rar"));
assert!(is_generated_par2_artifact_name(".weaver-par2-repair-abc"));
assert!(is_generated_par2_artifact_name(
"movie.rar.weaver-par2-backup.123"
));
assert!(!is_generated_par2_artifact_name("movie.rar"));
}
}