mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
fix(downloads): recover degraded connections and restart state
- recover persistent Aria2 connection-pool collapse without restarting stable or rate-limited pools - preserve sanitized browser context while failing closed on credential-bearing and malformed restart state - fence resume paths and harden portable and Torrent persistence with regression coverage
This commit is contained in:
+289
-2
@@ -1049,11 +1049,16 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
|
|||||||
// These values are accepted from users, browser extensions, or URLs and
|
// These values are accepted from users, browser extensions, or URLs and
|
||||||
// may contain credentials or bearer tokens. Portable queues keep their
|
// may contain credentials or bearer tokens. Portable queues keep their
|
||||||
// useful metadata, but never persist these values beside the executable.
|
// useful metadata, but never persist these values beside the executable.
|
||||||
let mut removed_transfer_context = false;
|
// Safe, stable browser context is sanitized in place so a normal captured
|
||||||
for key in ["password", "cookies", "headers", "mirrors", "proxy"] {
|
// download does not become unresumable merely because it has a Referer or
|
||||||
|
// User-Agent. Unknown and credential-bearing headers still fail closed.
|
||||||
|
let is_torrent = object.get("isTorrent").and_then(Value::as_bool) == Some(true);
|
||||||
|
let mut removed_transfer_context = sanitize_portable_request_headers(object);
|
||||||
|
for key in ["password", "cookies", "mirrors", "proxy"] {
|
||||||
if object
|
if object
|
||||||
.get(key)
|
.get(key)
|
||||||
.is_some_and(|value| !value.is_null() && !value_is_empty(value))
|
.is_some_and(|value| !value.is_null() && !value_is_empty(value))
|
||||||
|
&& !(is_torrent && matches!(key, "password" | "cookies"))
|
||||||
{
|
{
|
||||||
removed_transfer_context = true;
|
removed_transfer_context = true;
|
||||||
}
|
}
|
||||||
@@ -1127,6 +1132,190 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS: &[&str] = &[
|
||||||
|
"accept",
|
||||||
|
"accept-charset",
|
||||||
|
"accept-encoding",
|
||||||
|
"accept-language",
|
||||||
|
"cache-control",
|
||||||
|
"connection",
|
||||||
|
"dnt",
|
||||||
|
"host",
|
||||||
|
"if-match",
|
||||||
|
"if-modified-since",
|
||||||
|
"if-none-match",
|
||||||
|
"if-range",
|
||||||
|
"if-unmodified-since",
|
||||||
|
"origin",
|
||||||
|
"pragma",
|
||||||
|
"priority",
|
||||||
|
"range",
|
||||||
|
"referer",
|
||||||
|
"sec-ch-ua",
|
||||||
|
"sec-ch-ua-mobile",
|
||||||
|
"sec-ch-ua-platform",
|
||||||
|
"sec-fetch-dest",
|
||||||
|
"sec-fetch-mode",
|
||||||
|
"sec-fetch-site",
|
||||||
|
"sec-fetch-user",
|
||||||
|
"sec-gpc",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"user-agent",
|
||||||
|
"via",
|
||||||
|
"warning",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PORTABLE_PERSISTABLE_REQUEST_HEADERS: &[&str] = &[
|
||||||
|
"accept",
|
||||||
|
"accept-charset",
|
||||||
|
"accept-encoding",
|
||||||
|
"accept-language",
|
||||||
|
"cache-control",
|
||||||
|
"dnt",
|
||||||
|
"origin",
|
||||||
|
"pragma",
|
||||||
|
"priority",
|
||||||
|
"referer",
|
||||||
|
"sec-ch-ua",
|
||||||
|
"sec-ch-ua-mobile",
|
||||||
|
"sec-ch-ua-platform",
|
||||||
|
"sec-fetch-dest",
|
||||||
|
"sec-fetch-mode",
|
||||||
|
"sec-fetch-site",
|
||||||
|
"sec-fetch-user",
|
||||||
|
"sec-gpc",
|
||||||
|
"user-agent",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn portable_header_is_known_non_credential(name: &str) -> bool {
|
||||||
|
PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| *candidate == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn portable_header_is_persistable(name: &str) -> bool {
|
||||||
|
PORTABLE_PERSISTABLE_REQUEST_HEADERS
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| *candidate == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_portable_request_header_value(name: &str, value: &str) -> Option<String> {
|
||||||
|
if value.chars().any(char::is_control) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match name {
|
||||||
|
"referer" => {
|
||||||
|
let mut parsed = url::Url::parse(value).ok()?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _ = parsed.set_username("");
|
||||||
|
let _ = parsed.set_password(None);
|
||||||
|
parsed.set_query(None);
|
||||||
|
parsed.set_fragment(None);
|
||||||
|
Some(parsed.to_string())
|
||||||
|
}
|
||||||
|
"origin" => {
|
||||||
|
let parsed = url::Url::parse(value).ok()?;
|
||||||
|
matches!(parsed.scheme(), "http" | "https")
|
||||||
|
.then(|| parsed.origin().ascii_serialization())
|
||||||
|
}
|
||||||
|
_ => Some(value.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn portable_request_header_requires_recovery(name: &str, value: &str) -> bool {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if value.chars().any(char::is_control) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "referer" || name == "origin" {
|
||||||
|
let Ok(parsed) = url::Url::parse(value) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.query().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return name == "origin" && !parsed.path().is_empty() && parsed.path() != "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove unsafe request headers from portable persistence while retaining
|
||||||
|
/// sanitized, stable browser context. The boolean reports whether the input
|
||||||
|
/// contained credential-bearing, unknown, or malformed header context that
|
||||||
|
/// makes automatic restart unsafe. Torrent browser context is metadata-only:
|
||||||
|
/// it is removed without making a cached-metadata Torrent unresumable.
|
||||||
|
fn sanitize_portable_request_headers(object: &mut serde_json::Map<String, Value>) -> bool {
|
||||||
|
let Some(raw_value) = object.get("headers").cloned() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if raw_value.is_null() || value_is_empty(&raw_value) {
|
||||||
|
object.remove("headers");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(raw_headers) = raw_value.as_str() else {
|
||||||
|
object.remove("headers");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if object.get("isTorrent").and_then(Value::as_bool) == Some(true) {
|
||||||
|
object.remove("headers");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retained = Vec::new();
|
||||||
|
let mut removed_context = false;
|
||||||
|
for line in raw_headers.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((raw_name, raw_value)) = trimmed.split_once(':') else {
|
||||||
|
removed_context = true;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = raw_name.trim().to_ascii_lowercase();
|
||||||
|
if !portable_header_is_known_non_credential(&name) {
|
||||||
|
removed_context = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if portable_request_header_requires_recovery(&name, raw_value.trim()) {
|
||||||
|
removed_context = true;
|
||||||
|
}
|
||||||
|
let Some(sanitized_value) = sanitize_portable_request_header_value(&name, raw_value.trim())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if portable_header_is_persistable(&name) {
|
||||||
|
retained.push(format!("{}: {sanitized_value}", raw_name.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if retained.is_empty() {
|
||||||
|
object.remove("headers");
|
||||||
|
} else {
|
||||||
|
object.insert("headers".to_string(), Value::String(retained.join("\n")));
|
||||||
|
}
|
||||||
|
removed_context
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_portable_torrent_tracker_field(
|
fn sanitize_portable_torrent_tracker_field(
|
||||||
object: &mut serde_json::Map<String, Value>,
|
object: &mut serde_json::Map<String, Value>,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -2383,6 +2572,104 @@ mod tests {
|
|||||||
assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude");
|
assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portable_download_persistence_keeps_sanitized_safe_browser_context() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let mut connection = state.lock().unwrap();
|
||||||
|
let data = json!([{
|
||||||
|
"id": "download-safe-headers",
|
||||||
|
"status": "queued",
|
||||||
|
"queueId": "main",
|
||||||
|
"url": "https://example.com/file",
|
||||||
|
"headers": "Referer: https://example.com/page\nUser-Agent: Firelink-Test"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
replace_downloads(&mut connection, &data, true).unwrap();
|
||||||
|
|
||||||
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
|
assert_eq!(saved["status"], "queued");
|
||||||
|
assert_ne!(saved.get("resumable"), Some(&Value::Bool(false)));
|
||||||
|
assert_eq!(
|
||||||
|
saved["headers"],
|
||||||
|
"Referer: https://example.com/page\nUser-Agent: Firelink-Test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portable_download_persistence_rejects_sensitive_referer_context() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let mut connection = state.lock().unwrap();
|
||||||
|
let data = json!([{
|
||||||
|
"id": "download-sensitive-referer",
|
||||||
|
"status": "queued",
|
||||||
|
"queueId": "main",
|
||||||
|
"url": "https://example.com/file",
|
||||||
|
"headers": "Referer: https://example.com/page?token=secret#fragment"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
replace_downloads(&mut connection, &data, true).unwrap();
|
||||||
|
|
||||||
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
|
assert_eq!(saved["status"], "failed");
|
||||||
|
assert_eq!(saved["resumable"], false);
|
||||||
|
assert_eq!(saved["headers"], "Referer: https://example.com/page");
|
||||||
|
assert!(!saved.to_string().contains("secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portable_download_persistence_rejects_unknown_header_context() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let mut connection = state.lock().unwrap();
|
||||||
|
let data = json!([{
|
||||||
|
"id": "download-unknown-header",
|
||||||
|
"status": "queued",
|
||||||
|
"queueId": "main",
|
||||||
|
"url": "https://example.com/file",
|
||||||
|
"headers": "X-Request-Signature:"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
replace_downloads(&mut connection, &data, true).unwrap();
|
||||||
|
|
||||||
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
|
assert_eq!(saved["status"], "failed");
|
||||||
|
assert_eq!(saved["resumable"], false);
|
||||||
|
assert!(saved.get("headers").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portable_torrent_persistence_strips_metadata_credentials_without_blocking_restart() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let mut connection = state.lock().unwrap();
|
||||||
|
let data = json!([{
|
||||||
|
"id": "torrent-browser-context",
|
||||||
|
"status": "queued",
|
||||||
|
"queueId": "main",
|
||||||
|
"isTorrent": true,
|
||||||
|
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
||||||
|
"password": "metadata-only-secret",
|
||||||
|
"cookies": "session=metadata-only",
|
||||||
|
"headers": "User-Agent: Browser"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
replace_downloads(&mut connection, &data, true).unwrap();
|
||||||
|
|
||||||
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
|
assert_eq!(saved["status"], "queued");
|
||||||
|
assert_ne!(saved.get("resumable"), Some(&Value::Bool(false)));
|
||||||
|
assert!(saved.get("password").is_none());
|
||||||
|
assert!(saved.get("cookies").is_none());
|
||||||
|
assert!(saved.get("headers").is_none());
|
||||||
|
assert!(!saved.to_string().contains("metadata-only-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn download_state_commit_is_atomic_across_downloads_and_queues() {
|
fn download_state_commit_is_atomic_across_downloads_and_queues() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
+288
-3
@@ -12641,6 +12641,274 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_connection_pool_collapse_recovers_when_throughput_stays_healthy() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut observation = Aria2ConnectionObservation::default();
|
||||||
|
|
||||||
|
for offset in 0..3 {
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
(10 + offset) * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
13 * 1024 * 1024,
|
||||||
|
1.4 * 1024.0 * 1024.0,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(31),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"the first sustained underfill sample starts the recovery timer"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
14 * 1024 * 1024,
|
||||||
|
1.4 * 1024.0 * 1024.0,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(62),
|
||||||
|
),
|
||||||
|
Some(Aria2RecoveryReason::ConnectionPoolCollapse),
|
||||||
|
"a persistent 12/16 pool must recover even when remaining throughput is above half of peak"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_underfilled_connection_pool_does_not_trigger_recovery() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut observation = Aria2ConnectionObservation::default();
|
||||||
|
|
||||||
|
for offset in 0..3 {
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
(10 + offset) * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
13 * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(31),
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
14 * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(62),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"a stable 12/16 pool at the healthy rate is not a collapse"
|
||||||
|
);
|
||||||
|
assert_eq!(observation.recovery_attempts, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_connection_reduction_does_not_trigger_partial_recovery() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut observation = Aria2ConnectionObservation::default();
|
||||||
|
|
||||||
|
for offset in 0..3 {
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
(10 + offset) * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
13 * 1024 * 1024,
|
||||||
|
1.7 * 1024.0 * 1024.0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(31),
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
14 * 1024 * 1024,
|
||||||
|
1.7 * 1024.0 * 1024.0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(62),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"a reduced effective pool must not be compared with its stale larger baseline"
|
||||||
|
);
|
||||||
|
assert_eq!(observation.recovery_attempts, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_lost_connection_in_a_small_pool_does_not_trigger_partial_recovery() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut observation = Aria2ConnectionObservation::default();
|
||||||
|
|
||||||
|
for offset in 0..3 {
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
(10 + offset) * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
13 * 1024 * 1024,
|
||||||
|
1.7 * 1024.0 * 1024.0,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(31),
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
14 * 1024 * 1024,
|
||||||
|
1.7 * 1024.0 * 1024.0,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(62),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"a single lost connection in a small pool is not enough evidence of collapse"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_connection_after_a_healthy_pool_recovers_even_at_full_rate() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut observation = Aria2ConnectionObservation::default();
|
||||||
|
|
||||||
|
for offset in 0..3 {
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
(10 + offset) * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
13 * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(31),
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observe_aria2_connections(
|
||||||
|
&mut observation,
|
||||||
|
"gid-1",
|
||||||
|
"active",
|
||||||
|
8 * 1024 * 1024 * 1024,
|
||||||
|
14 * 1024 * 1024,
|
||||||
|
2.2 * 1024.0 * 1024.0,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
false,
|
||||||
|
start + Duration::from_secs(62),
|
||||||
|
),
|
||||||
|
Some(Aria2RecoveryReason::ConnectionPoolCollapse),
|
||||||
|
"a collapse to one connection must not depend on throughput falling first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_connection_pool_collapse_stays_quiet_while_speed_limited() {
|
fn partial_connection_pool_collapse_stays_quiet_while_speed_limited() {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -14357,6 +14625,7 @@ struct Aria2ConnectionObservation {
|
|||||||
no_progress_since: Option<Instant>,
|
no_progress_since: Option<Instant>,
|
||||||
last_refreshed_at: Option<Instant>,
|
last_refreshed_at: Option<Instant>,
|
||||||
peak_speed_bytes: f64,
|
peak_speed_bytes: f64,
|
||||||
|
peak_active_connections: i32,
|
||||||
last_completed: u64,
|
last_completed: u64,
|
||||||
last_logged_active_connections: Option<i32>,
|
last_logged_active_connections: Option<i32>,
|
||||||
last_connection_logged_at: Option<Instant>,
|
last_connection_logged_at: Option<Instant>,
|
||||||
@@ -14402,6 +14671,8 @@ const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024;
|
|||||||
const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0;
|
const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0;
|
||||||
const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20;
|
const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20;
|
||||||
const ARIA2_CONNECTION_POOL_DEGRADED_FRACTION: f64 = 0.75;
|
const ARIA2_CONNECTION_POOL_DEGRADED_FRACTION: f64 = 0.75;
|
||||||
|
const ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION: f64 = 0.80;
|
||||||
|
const ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY: i32 = 2;
|
||||||
const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3;
|
const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3;
|
||||||
const ARIA2_MAX_CONSECUTIVE_RECOVERY_ATTEMPTS: u8 = 3;
|
const ARIA2_MAX_CONSECUTIVE_RECOVERY_ATTEMPTS: u8 = 3;
|
||||||
const ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL: Duration = Duration::from_secs(30);
|
const ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
@@ -14498,6 +14769,12 @@ fn observe_aria2_connections_with_epoch(
|
|||||||
observation.healthy_speed_samples = observation
|
observation.healthy_speed_samples = observation
|
||||||
.healthy_speed_samples
|
.healthy_speed_samples
|
||||||
.saturating_add(1);
|
.saturating_add(1);
|
||||||
|
let observed_active_connections = active_connections
|
||||||
|
.min(effective_connections)
|
||||||
|
.max(0);
|
||||||
|
observation.peak_active_connections = observation
|
||||||
|
.peak_active_connections
|
||||||
|
.max(observed_active_connections);
|
||||||
}
|
}
|
||||||
let slow_throughput = !speed_limited
|
let slow_throughput = !speed_limited
|
||||||
&& observation.saw_multiple_connections
|
&& observation.saw_multiple_connections
|
||||||
@@ -14510,11 +14787,19 @@ fn observe_aria2_connections_with_epoch(
|
|||||||
&& observation.saw_multiple_connections
|
&& observation.saw_multiple_connections
|
||||||
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
||||||
&& effective_connections >= 4
|
&& effective_connections >= 4
|
||||||
&& (active_connections as f64)
|
&& observation.peak_active_connections.min(effective_connections) >= 4
|
||||||
<= (effective_connections as f64) * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION
|
&& active_connections.min(effective_connections)
|
||||||
|
<= observation
|
||||||
|
.peak_active_connections
|
||||||
|
.min(effective_connections)
|
||||||
|
.saturating_sub(ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY)
|
||||||
|
&& (active_connections.min(effective_connections) as f64)
|
||||||
|
<= (observation.peak_active_connections.min(effective_connections) as f64)
|
||||||
|
* ARIA2_CONNECTION_POOL_DEGRADED_FRACTION
|
||||||
&& observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY
|
&& observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY
|
||||||
&& speed_bytes > 0.0
|
&& speed_bytes > 0.0
|
||||||
&& speed_bytes < observation.peak_speed_bytes * 0.5;
|
&& speed_bytes
|
||||||
|
< observation.peak_speed_bytes * ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION;
|
||||||
let connection_pool_collapse = !speed_limited
|
let connection_pool_collapse = !speed_limited
|
||||||
&& observation.saw_multiple_connections
|
&& observation.saw_multiple_connections
|
||||||
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
||||||
|
|||||||
@@ -2459,6 +2459,8 @@ describe('useDownloadStore', () => {
|
|||||||
status: 'paused',
|
status: 'paused',
|
||||||
category: 'Other',
|
category: 'Other',
|
||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
|
username: 'alice',
|
||||||
|
headers: 'Referer: https://example.com/page',
|
||||||
credentialsRequired: true
|
credentialsRequired: true
|
||||||
}] as any[],
|
}] as any[],
|
||||||
backendRegisteredIds: new Set(['credential-resume-gated'])
|
backendRegisteredIds: new Set(['credential-resume-gated'])
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
|||||||
import type { Queue } from '../bindings/Queue';
|
import type { Queue } from '../bindings/Queue';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||||
import {
|
import {
|
||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
@@ -357,7 +357,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
if (item.isTorrent !== true && item.credentialsRequired === true
|
if (item.isTorrent !== true && item.credentialsRequired === true
|
||||||
&& !hasCredentialMaterial(item.password)
|
&& !hasCredentialMaterial(item.password)
|
||||||
&& !hasCredentialMaterial(item.cookies)
|
&& !hasCredentialMaterial(item.cookies)
|
||||||
&& !hasCredentialMaterial(item.headers)
|
&& !hasCredentialBearingHeaders(item.headers)
|
||||||
&& !hasCredentialMaterial(keychainPassword)) {
|
&& !hasCredentialMaterial(keychainPassword)) {
|
||||||
markCredentialsRequired(id);
|
markCredentialsRequired(id);
|
||||||
return false;
|
return false;
|
||||||
@@ -1172,7 +1172,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
const credentialsUpdated = (['password', 'cookies', 'headers'] as const)
|
const credentialsUpdated = (['password', 'cookies', 'headers'] as const)
|
||||||
.some(field => Object.prototype.hasOwnProperty.call(updates, field));
|
.some(field => Object.prototype.hasOwnProperty.call(updates, field));
|
||||||
const nextCredentialMaterial = (['password', 'cookies', 'headers'] as const)
|
const nextCredentialMaterial = (['password', 'cookies', 'headers'] as const)
|
||||||
.some(field => hasCredentialMaterial(
|
.some(field => field === 'headers'
|
||||||
|
? hasCredentialBearingHeaders(
|
||||||
|
Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field]
|
||||||
|
)
|
||||||
|
: hasCredentialMaterial(
|
||||||
Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field]
|
Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field]
|
||||||
));
|
));
|
||||||
const normalizedUpdates = {
|
const normalizedUpdates = {
|
||||||
@@ -1290,7 +1294,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true
|
if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true
|
||||||
&& !hasCredentialMaterial(targetItem.password)
|
&& !hasCredentialMaterial(targetItem.password)
|
||||||
&& !hasCredentialMaterial(targetItem.cookies)
|
&& !hasCredentialMaterial(targetItem.cookies)
|
||||||
&& !hasCredentialMaterial(targetItem.headers)) {
|
&& !hasCredentialBearingHeaders(targetItem.headers)) {
|
||||||
if (!resumeWithoutCredentials) {
|
if (!resumeWithoutCredentials) {
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const login = getSiteLogin(targetItem.url, settings);
|
const login = getSiteLogin(targetItem.url, settings);
|
||||||
@@ -2677,7 +2681,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
if (item.isTorrent !== true && item.credentialsRequired === true
|
if (item.isTorrent !== true && item.credentialsRequired === true
|
||||||
&& !hasCredentialMaterial(item.password)
|
&& !hasCredentialMaterial(item.password)
|
||||||
&& !hasCredentialMaterial(item.cookies)
|
&& !hasCredentialMaterial(item.cookies)
|
||||||
&& !hasCredentialMaterial(item.headers)
|
&& !hasCredentialBearingHeaders(item.headers)
|
||||||
&& !hasCredentialMaterial(keychainPassword)) {
|
&& !hasCredentialMaterial(keychainPassword)) {
|
||||||
markCredentialsRequired(item.id);
|
markCredentialsRequired(item.id);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -89,6 +89,57 @@ describe('download persistence progress snapshots', () => {
|
|||||||
expect(persisted.headers).toBeUndefined();
|
expect(persisted.headers).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not gate browser request context headers after restart', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
headers: 'Referer: https://example.com/page\nUser-Agent: Browser',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted.credentialsRequired).toBeUndefined();
|
||||||
|
expect(persisted.headers).toBe('Referer: https://example.com/page\nUser-Agent: Browser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires confirmation when a Referer contains restart-sensitive URL context', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
headers: 'Referer: https://example.com/page?session=secret#part',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted.credentialsRequired).toBe(true);
|
||||||
|
expect(persisted.headers).toBe('Referer: https://example.com/page');
|
||||||
|
expect(JSON.stringify(persisted)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks username-only authentication as requiring credentials after restart', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
username: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted.credentialsRequired).toBe(true);
|
||||||
|
expect(persisted.username).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unknown custom headers that may carry credentials', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
headers: 'X-Download-Token: secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted.credentialsRequired).toBe(true);
|
||||||
|
expect(persisted.headers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for empty unknown credential headers', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
headers: 'X-Auth-Token:',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted.credentialsRequired).toBe(true);
|
||||||
|
expect(persisted.headers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not create a credential gate for Torrent metadata context', () => {
|
it('does not create a credential gate for Torrent metadata context', () => {
|
||||||
const persisted = redactDownloadForPersistence({
|
const persisted = redactDownloadForPersistence({
|
||||||
...item('paused'),
|
...item('paused'),
|
||||||
|
|||||||
+168
-2
@@ -580,13 +580,163 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
|||||||
* session (see `enqueue_download` payloads) but are stripped at the
|
* session (see `enqueue_download` payloads) but are stripped at the
|
||||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||||
*/
|
*/
|
||||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies'] as const;
|
||||||
|
// Browser captures commonly include request context such as Referer and
|
||||||
|
// User-Agent. Keep this an explicit allowlist so those known non-credential
|
||||||
|
// headers do not gate a restart, while an unknown custom header fails closed
|
||||||
|
// and remains eligible for a credential-confirmation retry.
|
||||||
|
const NON_CREDENTIAL_REQUEST_HEADERS = new Set([
|
||||||
|
'accept',
|
||||||
|
'accept-charset',
|
||||||
|
'accept-encoding',
|
||||||
|
'accept-language',
|
||||||
|
'cache-control',
|
||||||
|
'connection',
|
||||||
|
'dnt',
|
||||||
|
'host',
|
||||||
|
'if-match',
|
||||||
|
'if-modified-since',
|
||||||
|
'if-none-match',
|
||||||
|
'if-range',
|
||||||
|
'if-unmodified-since',
|
||||||
|
'origin',
|
||||||
|
'pragma',
|
||||||
|
'priority',
|
||||||
|
'range',
|
||||||
|
'referer',
|
||||||
|
'sec-ch-ua',
|
||||||
|
'sec-ch-ua-mobile',
|
||||||
|
'sec-ch-ua-platform',
|
||||||
|
'sec-fetch-dest',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'sec-fetch-site',
|
||||||
|
'sec-fetch-user',
|
||||||
|
'sec-gpc',
|
||||||
|
'te',
|
||||||
|
'trailer',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade',
|
||||||
|
'user-agent',
|
||||||
|
'via',
|
||||||
|
'warning',
|
||||||
|
]);
|
||||||
|
// Only stable request context is safe to carry into a later lifecycle. Range,
|
||||||
|
// conditional, hop-by-hop, and routing headers describe the old HTTP request
|
||||||
|
// and can conflict with Aria2's own resume negotiation.
|
||||||
|
const PERSISTABLE_REQUEST_HEADERS = new Set([
|
||||||
|
'accept',
|
||||||
|
'accept-charset',
|
||||||
|
'accept-encoding',
|
||||||
|
'accept-language',
|
||||||
|
'cache-control',
|
||||||
|
'dnt',
|
||||||
|
'origin',
|
||||||
|
'pragma',
|
||||||
|
'priority',
|
||||||
|
'referer',
|
||||||
|
'sec-ch-ua',
|
||||||
|
'sec-ch-ua-mobile',
|
||||||
|
'sec-ch-ua-platform',
|
||||||
|
'sec-fetch-dest',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'sec-fetch-site',
|
||||||
|
'sec-fetch-user',
|
||||||
|
'sec-gpc',
|
||||||
|
'user-agent',
|
||||||
|
]);
|
||||||
const VOLATILE_PROGRESS_STATUSES = new Set([
|
const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||||
'downloading',
|
'downloading',
|
||||||
'verifying',
|
'verifying',
|
||||||
'seeding'
|
'seeding'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0;
|
||||||
|
|
||||||
|
const headerValueRequiresRecovery = (name: string, value: string): boolean => {
|
||||||
|
if (!value.trim()) return false;
|
||||||
|
if (/[\u0000-\u001f\u007f]/.test(value)) return true;
|
||||||
|
|
||||||
|
if (name === 'referer' || name === 'origin') {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true;
|
||||||
|
if (parsed.username || parsed.password || parsed.search || parsed.hash) return true;
|
||||||
|
// Origin must not carry a path that would be silently discarded by the
|
||||||
|
// persistence sanitizer.
|
||||||
|
return name === 'origin' && parsed.pathname !== '/' && parsed.pathname !== '';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasCredentialBearingHeaders = (headers: string | null | undefined): boolean => {
|
||||||
|
if (!hasCredentialMaterial(headers)) return false;
|
||||||
|
return headers!.split(/\r?\n/).some(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const separator = trimmed.indexOf(':');
|
||||||
|
if (separator <= 0) return true;
|
||||||
|
const name = trimmed.slice(0, separator).trim().toLowerCase();
|
||||||
|
// An unknown header is sensitive even when its value is empty: the
|
||||||
|
// redacted value cannot tell us whether it was a placeholder for a token
|
||||||
|
// or an intentionally empty request field.
|
||||||
|
return !NON_CREDENTIAL_REQUEST_HEADERS.has(name)
|
||||||
|
|| headerValueRequiresRecovery(name, trimmed.slice(separator + 1).trim());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistableHeaderValue = (name: string, value: string): string | undefined => {
|
||||||
|
if (/[\u0000-\u001f\u007f]/.test(value)) return undefined;
|
||||||
|
|
||||||
|
if (name === 'referer') {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||||
|
parsed.username = '';
|
||||||
|
parsed.password = '';
|
||||||
|
// A Referer query or fragment can carry a signed URL or user token.
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'origin') {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||||
|
return parsed.origin;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistableRequestHeaders = (headers: string | null | undefined): string | undefined => {
|
||||||
|
if (!hasCredentialMaterial(headers)) return undefined;
|
||||||
|
|
||||||
|
const lines = headers!.split(/\r?\n/).flatMap(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const separator = trimmed.indexOf(':');
|
||||||
|
if (separator <= 0) return [];
|
||||||
|
const name = trimmed.slice(0, separator).trim().toLowerCase();
|
||||||
|
if (!PERSISTABLE_REQUEST_HEADERS.has(name)) return [];
|
||||||
|
const value = persistableHeaderValue(name, trimmed.slice(separator + 1).trim());
|
||||||
|
return value === undefined ? [] : [`${trimmed.slice(0, separator).trim()}: ${value}`];
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines.join('\n') : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||||
@@ -596,6 +746,12 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
|
|||||||
* state stay in memory to avoid a database write for every progress tick.
|
* state stay in memory to avoid a database write for every progress tick.
|
||||||
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
||||||
* processing snapshots remain useful across restart and reconfiguration.
|
* processing snapshots remain useful across restart and reconfiguration.
|
||||||
|
* The credential marker is narrower than the redacted field set: ordinary
|
||||||
|
* browser request context such as Referer does not require a credentialed
|
||||||
|
* restart, while passwords, cookies, usernames, and unknown custom headers
|
||||||
|
* do. A sanitized subset of stable browser context is retained so a resumed
|
||||||
|
* download keeps anti-hotlink and content-negotiation context without
|
||||||
|
* persisting credentials or stale range state.
|
||||||
*
|
*
|
||||||
* Note: standard persistence intentionally retains `url` because it is the
|
* Note: standard persistence intentionally retains `url` because it is the
|
||||||
* download source. The backend applies a stricter portable-mode policy: URL
|
* download source. The backend applies a stricter portable-mode policy: URL
|
||||||
@@ -611,7 +767,10 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
|
|||||||
delete copy.credentialsRequired;
|
delete copy.credentialsRequired;
|
||||||
delete copy.username;
|
delete copy.username;
|
||||||
} else if (item.credentialsRequired === true
|
} else if (item.credentialsRequired === true
|
||||||
|| DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) {
|
|| hasCredentialMaterial(item.username)
|
||||||
|
|| hasCredentialMaterial(item.password)
|
||||||
|
|| hasCredentialMaterial(item.cookies)
|
||||||
|
|| hasCredentialBearingHeaders(item.headers)) {
|
||||||
copy.credentialsRequired = true;
|
copy.credentialsRequired = true;
|
||||||
}
|
}
|
||||||
delete copy.fraction;
|
delete copy.fraction;
|
||||||
@@ -625,6 +784,13 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
|
|||||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||||
delete copy[field];
|
delete copy[field];
|
||||||
}
|
}
|
||||||
|
if (item.isTorrent === true) {
|
||||||
|
delete copy.headers;
|
||||||
|
} else {
|
||||||
|
const savedHeaders = persistableRequestHeaders(item.headers);
|
||||||
|
if (savedHeaders) copy.headers = savedHeaders;
|
||||||
|
else delete copy.headers;
|
||||||
|
}
|
||||||
// Error classification is derived from the live native state and must not
|
// Error classification is derived from the live native state and must not
|
||||||
// become a persistence field or influence a new lifecycle after restart.
|
// become a persistence field or influence a new lifecycle after restart.
|
||||||
delete copy.lastErrorKind;
|
delete copy.lastErrorKind;
|
||||||
|
|||||||
Reference in New Issue
Block a user