chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use aria2_rust_pro_compat::{global_option_specs, per_download_option_specs};
|
||||
use aria2_rust_pro_core::{DownloadHandle, RequestGroup};
|
||||
use base64::Engine;
|
||||
|
||||
use crate::{
|
||||
model::RpcValue,
|
||||
xmlrpc::{XmlRpcMember, XmlRpcValue},
|
||||
};
|
||||
|
||||
/// Builds the unified option surface exposed by `getGlobalOption`.
|
||||
pub(super) fn option_specs_for_global_view() -> Vec<&'static aria2_rust_pro_compat::OptionSpec> {
|
||||
let mut specs = global_option_specs();
|
||||
let mut seen = specs.iter().map(|spec| spec.name).collect::<BTreeSet<_>>();
|
||||
for spec in per_download_option_specs() {
|
||||
if seen.insert(spec.name) {
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into an `i64` for RPC payload rendering.
|
||||
pub(super) fn i64_from_usize(value: usize) -> i64 {
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into a `u32` for engine-facing counters.
|
||||
pub(super) fn u32_from_usize(value: usize) -> u32 {
|
||||
u32::try_from(value).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Lossily converts a `usize` into a `u64` for RPC payload rendering.
|
||||
pub(super) fn u64_from_usize(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Converts a non-negative JSON-RPC integer into a platform `usize`.
|
||||
pub(super) fn usize_from_i64(value: i64) -> Option<usize> {
|
||||
usize::try_from(value).ok()
|
||||
}
|
||||
|
||||
/// Lossily converts a `u64` into a `usize` for local indexing.
|
||||
pub(super) fn usize_from_u64(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Returns the first per-download option key that aria2 forbids through `changeOption`.
|
||||
pub(super) fn first_forbidden_change_option_key(map: &BTreeMap<String, RpcValue>) -> Option<&str> {
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"dry-run",
|
||||
"metalink-base-uri",
|
||||
"parameterized-uri",
|
||||
"pause",
|
||||
"piece-length",
|
||||
"rpc-save-upload-metadata",
|
||||
];
|
||||
FORBIDDEN
|
||||
.iter()
|
||||
.find_map(|name| map.contains_key(*name).then_some(*name))
|
||||
}
|
||||
|
||||
/// Returns the first global option key that aria2 forbids through `changeGlobalOption`.
|
||||
pub(super) fn first_forbidden_change_global_option_key(
|
||||
map: &BTreeMap<String, RpcValue>,
|
||||
) -> Option<&str> {
|
||||
const FORBIDDEN: &[&str] = &["checksum", "index-out", "out", "pause", "select-file"];
|
||||
FORBIDDEN
|
||||
.iter()
|
||||
.find_map(|name| map.contains_key(*name).then_some(*name))
|
||||
}
|
||||
|
||||
/// Parses a required RPC URI parameter into a normalized URI list.
|
||||
pub(super) fn parse_uri_list_param(value: &RpcValue) -> Result<Vec<String>, String> {
|
||||
match value {
|
||||
RpcValue::String(uri) => Ok(vec![uri.clone()]),
|
||||
RpcValue::Array(items) => {
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(uri) => uris.push(uri.clone()),
|
||||
_ => return Err("uri array must contain only strings".to_owned()),
|
||||
}
|
||||
}
|
||||
if uris.is_empty() {
|
||||
return Err("uri array must not be empty".to_owned());
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
_ => Err("uris must be an array of strings".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a URI array parameter that may legally be empty.
|
||||
pub(super) fn parse_uri_array_allow_empty(
|
||||
value: &RpcValue,
|
||||
label: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let RpcValue::Array(items) = value else {
|
||||
return Err(format!("{label} must be an array of strings"));
|
||||
};
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
if let RpcValue::String(uri) = item {
|
||||
uris.push(uri.clone());
|
||||
}
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
|
||||
/// Parses an optional webseed URI array for add-torrent style methods.
|
||||
pub(super) fn parse_optional_uri_array(
|
||||
value: &RpcValue,
|
||||
method: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
match value {
|
||||
RpcValue::Array(items) => {
|
||||
let mut uris = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(uri) => uris.push(uri.clone()),
|
||||
_ => {
|
||||
return Err(format!("{method} webseed uris must contain only strings"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(uris)
|
||||
}
|
||||
_ => Err(format!("{method} webseed uris must be an array of strings")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an optional RPC options object into owned key-value entries.
|
||||
pub(super) fn parse_optional_option_object(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Vec<(String, RpcValue)>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let RpcValue::Object(options) = value else {
|
||||
return Err(format!("{method} options must be a struct/object"));
|
||||
};
|
||||
Ok(options
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses an optional queue position parameter.
|
||||
pub(super) fn parse_optional_position(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Option<usize>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
RpcValue::Number(value) if *value >= 0 => Ok(usize_from_i64(*value)),
|
||||
_ => Err(format!("{method} position must be a non-negative integer")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a required 1-based file index parameter.
|
||||
pub(super) fn parse_required_file_index(value: &RpcValue) -> Option<usize> {
|
||||
match value {
|
||||
RpcValue::Number(value) if *value >= 1 => usize_from_i64(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a URI looks actionable for aria2-style add methods.
|
||||
pub(super) fn is_rpc_uri_candidate(uri: &str) -> bool {
|
||||
rpc_uri_has_ascii_prefix(uri, "magnet:?")
|
||||
|| uri.contains("://")
|
||||
|| rpc_uri_has_ascii_suffix(uri, ".torrent")
|
||||
}
|
||||
|
||||
/// Returns whether a URI starts with an ASCII prefix, ignoring case.
|
||||
pub(super) fn rpc_uri_has_ascii_prefix(uri: &str, prefix: &str) -> bool {
|
||||
uri.get(..prefix.len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
/// Returns whether a URI ends with an ASCII suffix, ignoring case.
|
||||
pub(super) fn rpc_uri_has_ascii_suffix(uri: &str, suffix: &str) -> bool {
|
||||
uri.get(uri.len().saturating_sub(suffix.len())..)
|
||||
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
|
||||
}
|
||||
|
||||
/// Extracts a display file name from a URI when one is obvious.
|
||||
pub(super) fn rpc_uri_file_name(uri: &str) -> Option<String> {
|
||||
let trimmed = uri
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(uri)
|
||||
.trim_end_matches('/');
|
||||
let candidate = trimmed.rsplit('/').next()?;
|
||||
if candidate.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(candidate.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an optional status-field allowlist parameter.
|
||||
pub(super) fn parse_optional_status_keys(
|
||||
value: Option<&RpcValue>,
|
||||
method: &str,
|
||||
) -> Result<Option<BTreeSet<String>>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let RpcValue::Array(items) = value else {
|
||||
return Err(format!("{method} keys must be an array of strings"));
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut keys = BTreeSet::new();
|
||||
for item in items {
|
||||
match item {
|
||||
RpcValue::String(key) => {
|
||||
keys.insert(key.clone());
|
||||
}
|
||||
_ => return Err(format!("{method} keys must contain only strings")),
|
||||
}
|
||||
}
|
||||
Ok(Some(keys))
|
||||
}
|
||||
|
||||
/// Filters a status payload down to the requested field set.
|
||||
pub(super) fn filter_status_payload(
|
||||
payload: RpcValue,
|
||||
keys: Option<&BTreeSet<String>>,
|
||||
) -> RpcValue {
|
||||
let Some(keys) = keys else {
|
||||
return payload;
|
||||
};
|
||||
match payload {
|
||||
RpcValue::Object(fields) => RpcValue::Object(
|
||||
fields
|
||||
.into_iter()
|
||||
.filter(|(key, _)| keys.contains(key))
|
||||
.collect(),
|
||||
),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies RPC option values to a request group using aria2's stringly option model.
|
||||
pub(super) fn apply_group_options(group: &mut RequestGroup, options: Vec<(String, RpcValue)>) {
|
||||
for (key, value) in options {
|
||||
match value {
|
||||
RpcValue::String(value) => group.set_option(key, value),
|
||||
RpcValue::Number(value) => group.set_option(key, value.to_string()),
|
||||
RpcValue::Bool(value) => group.set_option(key, if value { "true" } else { "false" }),
|
||||
RpcValue::Null => group.set_option(key, ""),
|
||||
RpcValue::Array(_) | RpcValue::Object(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies already-normalized string options to a request group directly.
|
||||
pub(super) fn apply_group_string_options(group: &mut RequestGroup, options: Vec<(String, String)>) {
|
||||
for (key, value) in options {
|
||||
group.set_option(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds implied request-group options from a metalink plan entry.
|
||||
pub(super) fn metalink_default_options(
|
||||
entry: &aria2_rust_pro_protocol::metalink::MetalinkDownloadPlanEntry,
|
||||
) -> Vec<(String, RpcValue)> {
|
||||
let mut options = Vec::new();
|
||||
if !entry.file_name.trim().is_empty() {
|
||||
options.push(("out".to_owned(), RpcValue::String(entry.file_name.clone())));
|
||||
}
|
||||
if let Some(checksum) = &entry.checksum {
|
||||
options.push((
|
||||
"checksum".to_owned(),
|
||||
RpcValue::String(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
|
||||
));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Decodes a base64 metalink payload when the caller did not send raw XML.
|
||||
pub(super) fn decode_metalink_payload(value: &str) -> Option<String> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(value.as_bytes())
|
||||
.ok()?;
|
||||
let text = String::from_utf8(bytes).ok()?;
|
||||
text.contains("<metalink").then_some(text)
|
||||
}
|
||||
|
||||
/// Slices a download-handle list using aria2's positive and negative offset rules.
|
||||
pub(super) fn slice_handles_by_offset(
|
||||
handles: Vec<DownloadHandle>,
|
||||
offset: i64,
|
||||
max: usize,
|
||||
) -> Vec<DownloadHandle> {
|
||||
if max == 0 || handles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if offset >= 0 {
|
||||
return handles
|
||||
.into_iter()
|
||||
.skip(usize_from_i64(offset).unwrap_or(usize::MAX))
|
||||
.take(max)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let reversed = handles.into_iter().rev().collect::<Vec<_>>();
|
||||
let start = offset
|
||||
.checked_neg()
|
||||
.and_then(|value| value.checked_sub(1))
|
||||
.and_then(usize_from_i64)
|
||||
.unwrap_or_default();
|
||||
reversed.into_iter().skip(start).take(max).collect()
|
||||
}
|
||||
|
||||
/// Looks up a named XML-RPC struct member.
|
||||
pub(super) fn xmlrpc_member_value<'a>(
|
||||
members: &'a [XmlRpcMember],
|
||||
name: &str,
|
||||
) -> Option<&'a XmlRpcValue> {
|
||||
members
|
||||
.iter()
|
||||
.find(|member| member.name == name)
|
||||
.map(|member| &member.value)
|
||||
}
|
||||
|
||||
/// Parses the completed byte count from a `Content-Range` header value.
|
||||
pub(super) fn parse_content_range_completed_length(value: &str) -> Option<u64> {
|
||||
let mut parts = value.split_whitespace();
|
||||
let unit = parts.next()?;
|
||||
if !unit.eq_ignore_ascii_case("bytes") {
|
||||
return None;
|
||||
}
|
||||
let range = parts.next()?;
|
||||
let (start, end) = range.split_once('-')?;
|
||||
let start = start.parse::<u64>().ok()?;
|
||||
let end = end.parse::<u64>().ok()?;
|
||||
if end < start {
|
||||
return None;
|
||||
}
|
||||
Some(end - start + 1)
|
||||
}
|
||||
|
||||
/// Returns whether an HTTP status should preserve retry eligibility.
|
||||
pub(super) fn is_retry_relevant_status(status: u16) -> bool {
|
||||
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
Reference in New Issue
Block a user