mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): preserve authenticated capture metadata
Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
This commit is contained in:
@@ -63,9 +63,18 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
cookies: Option<String>,
|
||||
#[serde(default)]
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionCookieScope {
|
||||
pub url: String,
|
||||
pub cookies: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionDownload {
|
||||
@@ -75,6 +84,7 @@ pub struct ExtensionDownload {
|
||||
filename: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
}
|
||||
|
||||
@@ -314,7 +324,7 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
let mut seen = HashSet::new();
|
||||
let urls = payload
|
||||
.urls
|
||||
@@ -345,13 +355,26 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
// request headers, but drop Cookie headers and the dedicated cookie field
|
||||
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||
let cookies = if !payload.media && urls.len() == 1 {
|
||||
payload
|
||||
.cookies
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
||||
let mut scopes = payload.cookie_scopes.take().unwrap_or_default();
|
||||
if let Some(cookies) = payload.cookies.take() {
|
||||
if !cookies.trim().is_empty() {
|
||||
scopes.push(ExtensionCookieScope {
|
||||
url: urls[0].clone(),
|
||||
cookies,
|
||||
});
|
||||
}
|
||||
}
|
||||
normalize_cookie_scopes(scopes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cookies = cookie_scopes.as_ref().and_then(|scopes| {
|
||||
scopes
|
||||
.iter()
|
||||
.find(|scope| same_origin_url(&scope.url, &urls[0]))
|
||||
.map(|scope| scope.cookies.clone())
|
||||
});
|
||||
|
||||
Some(ExtensionDownload {
|
||||
urls,
|
||||
@@ -365,10 +388,62 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
// builds pay for a doomed metadata request before retrying. Ordinary
|
||||
// captured downloads still need their exact request cookies.
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_cookie_scopes(scopes: Vec<ExtensionCookieScope>) -> Option<Vec<ExtensionCookieScope>> {
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen_origins = HashSet::new();
|
||||
|
||||
for scope in scopes {
|
||||
let Ok(url) = Url::parse(scope.url.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
continue;
|
||||
}
|
||||
let cookies = scope.cookies.trim();
|
||||
if cookies.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
continue;
|
||||
};
|
||||
let origin = format!(
|
||||
"{}://{}:{}",
|
||||
url.scheme(),
|
||||
host,
|
||||
url.port_or_known_default().unwrap_or(443)
|
||||
);
|
||||
if !seen_origins.insert(origin) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(ExtensionCookieScope {
|
||||
url: url.to_string(),
|
||||
cookies: cookies.to_string(),
|
||||
});
|
||||
if normalized.len() >= 16 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn same_origin_url(left: &str, right: &str) -> bool {
|
||||
let Some(left) = Url::parse(left).ok() else {
|
||||
return false;
|
||||
};
|
||||
let Some(right) = Url::parse(right).ok() else {
|
||||
return false;
|
||||
};
|
||||
left.scheme() == right.scheme()
|
||||
&& left.host() == right.host()
|
||||
&& left.port_or_known_default() == right.port_or_known_default()
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
let headers = headers?;
|
||||
if !media {
|
||||
@@ -532,7 +607,7 @@ mod tests {
|
||||
use super::{
|
||||
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
|
||||
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
ExtensionCookieScope, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
@@ -613,6 +688,7 @@ mod tests {
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
});
|
||||
|
||||
@@ -640,6 +716,7 @@ mod tests {
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
@@ -658,6 +735,7 @@ mod tests {
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
@@ -669,6 +747,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
|
||||
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
|
||||
silent: true,
|
||||
filename: Some("report.zip".to_string()),
|
||||
headers: None,
|
||||
cookies: Some("SID=mail-session".to_string()),
|
||||
cookie_scopes: Some(vec![
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/".to_string(),
|
||||
cookies: "SID=mail-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://accounts.google.com/".to_string(),
|
||||
cookies: "SID=account-session".to_string(),
|
||||
},
|
||||
ExtensionCookieScope {
|
||||
url: "https://mail.google.com/another-path".to_string(),
|
||||
cookies: "duplicate=ignored".to_string(),
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
|
||||
assert_eq!(
|
||||
download
|
||||
.cookie_scopes
|
||||
.as_ref()
|
||||
.map(|scopes| scopes.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
download.cookie_scopes.as_ref().unwrap()[1].cookies,
|
||||
"SID=account-session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -681,6 +800,7 @@ mod tests {
|
||||
filename: None,
|
||||
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
+173
-14
@@ -116,7 +116,20 @@ fn filename_from_url_path(raw_url: &str) -> Option<String> {
|
||||
// If the escape sequence cannot form valid UTF-8, retain the raw segment
|
||||
// so a malformed URL cannot erase an otherwise usable filename.
|
||||
let decoded = percent_decode_metadata_value(last).unwrap_or_else(|| last.to_string());
|
||||
sanitize_metadata_filename(&decoded)
|
||||
let filename = sanitize_metadata_filename(&decoded)?;
|
||||
let is_gmail_profile_path = parsed.host_str() == Some("mail.google.com")
|
||||
&& parsed.path().contains("/mail/u/");
|
||||
let is_gmail_profile_number = is_gmail_profile_path
|
||||
&& filename.chars().all(|character| character.is_ascii_digit());
|
||||
(!is_weak_url_filename(&filename) && !is_gmail_profile_number).then_some(filename)
|
||||
}
|
||||
|
||||
fn is_weak_url_filename(filename: &str) -> bool {
|
||||
let normalized = filename.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"identifier" | "download" | "view" | "uc"
|
||||
)
|
||||
}
|
||||
|
||||
fn metadata_filename_from_response(
|
||||
@@ -129,11 +142,13 @@ fn metadata_filename_from_response(
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(filename_from_content_disposition)
|
||||
.filter(|filename| !is_weak_url_filename(filename))
|
||||
{
|
||||
return filename;
|
||||
}
|
||||
|
||||
filename_from_url_disposition_query(current_url)
|
||||
.filter(|filename| !is_weak_url_filename(filename))
|
||||
.or_else(|| filename_from_url_path(original_url))
|
||||
.or_else(|| filename_from_url_path(current_url))
|
||||
.unwrap_or_else(|| "download".to_string())
|
||||
@@ -149,7 +164,11 @@ fn metadata_response_error(status: reqwest::StatusCode) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>) -> bool {
|
||||
fn metadata_cookie_header_present(
|
||||
headers: Option<&str>,
|
||||
cookies: Option<&str>,
|
||||
cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>,
|
||||
) -> bool {
|
||||
cookies.is_some_and(|value| !value.trim().is_empty())
|
||||
|| headers.is_some_and(|value| {
|
||||
value.lines().any(|line| {
|
||||
@@ -159,11 +178,14 @@ fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>)
|
||||
name.trim().eq_ignore_ascii_case("cookie") && !value.trim().is_empty()
|
||||
})
|
||||
})
|
||||
|| cookie_scopes.is_some_and(|scopes| {
|
||||
scopes.iter().any(|scope| !scope.cookies.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_headers(
|
||||
headers: Option<&str>,
|
||||
cookies: Option<&str>,
|
||||
cookie_header: Option<&str>,
|
||||
include_cookies: bool,
|
||||
) -> reqwest::header::HeaderMap {
|
||||
let mut header_map = reqwest::header::HeaderMap::new();
|
||||
@@ -191,8 +213,11 @@ fn metadata_headers(
|
||||
}
|
||||
|
||||
if include_cookies {
|
||||
if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Ok(value) = reqwest::header::HeaderValue::from_str(cookies) {
|
||||
if let Some(cookie_header) = cookie_header
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Ok(value) = reqwest::header::HeaderValue::from_str(cookie_header) {
|
||||
header_map.insert(reqwest::header::COOKIE, value);
|
||||
}
|
||||
}
|
||||
@@ -1592,6 +1617,41 @@ fn should_send_metadata_credentials(
|
||||
.is_some_and(|(original, current)| same_origin(original, current))
|
||||
}
|
||||
|
||||
fn cookie_scope_for_url<'a>(
|
||||
current_url: &str,
|
||||
cookie_scopes: Option<&'a [extension_server::ExtensionCookieScope]>,
|
||||
) -> Option<&'a str> {
|
||||
let current = reqwest::Url::parse(current_url).ok()?;
|
||||
cookie_scopes?.iter().find_map(|scope| {
|
||||
let scope_url = reqwest::Url::parse(&scope.url).ok()?;
|
||||
let same_googleusercontent_site = scope_url.scheme() == current.scheme()
|
||||
&& scope_url.port_or_known_default() == current.port_or_known_default()
|
||||
&& scope_url.host_str() == Some("googleusercontent.com")
|
||||
&& current
|
||||
.host_str()
|
||||
.is_some_and(|host| host.ends_with(".googleusercontent.com"));
|
||||
(same_origin(&scope_url, ¤t) || same_googleusercontent_site)
|
||||
.then_some(scope.cookies.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) -> Option<String> {
|
||||
let url = reqwest::Url::parse(current_url).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let path = url.path().to_ascii_lowercase();
|
||||
let content_type = content_type.unwrap_or_default().to_ascii_lowercase();
|
||||
let is_html = content_type.starts_with("text/html")
|
||||
|| content_type.starts_with("application/xhtml+xml");
|
||||
let is_google_auth_host = host == "accounts.google.com"
|
||||
&& (path.contains("signin")
|
||||
|| path.contains("servicelogin")
|
||||
|| url.query().is_some_and(|query| query.contains("continue=")));
|
||||
|
||||
(is_google_auth_host && (is_html || path.contains("signin"))).then(|| {
|
||||
"The server returned a Google sign-in page instead of the requested attachment. Re-authenticate in the browser and try again.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed.
|
||||
#[tauri::command]
|
||||
async fn fetch_metadata(
|
||||
@@ -1601,6 +1661,7 @@ async fn fetch_metadata(
|
||||
password: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<extension_server::ExtensionCookieScope>>,
|
||||
proxy: Option<String>,
|
||||
defer_cookies: Option<bool>,
|
||||
) -> Result<MetadataResponse, String> {
|
||||
@@ -1609,7 +1670,11 @@ async fn fetch_metadata(
|
||||
let mut current_url = url.clone();
|
||||
let original_origin = reqwest::Url::parse(&url).ok();
|
||||
let mut redirects = 0;
|
||||
let cookies_available = metadata_cookie_header_present(headers.as_deref(), cookies.as_deref());
|
||||
let cookies_available = metadata_cookie_header_present(
|
||||
headers.as_deref(),
|
||||
cookies.as_deref(),
|
||||
cookie_scopes.as_deref(),
|
||||
);
|
||||
let defer_cookies = defer_cookies.unwrap_or(false);
|
||||
let mut send_cookies = !defer_cookies || !cookies_available;
|
||||
let mut cookie_retry_attempted = false;
|
||||
@@ -1657,16 +1722,29 @@ async fn fetch_metadata(
|
||||
}
|
||||
|
||||
let current_origin = reqwest::Url::parse(¤t_url).ok();
|
||||
let should_send_auth = should_send_metadata_credentials(
|
||||
let same_origin_credentials = should_send_metadata_credentials(
|
||||
original_origin.as_ref(),
|
||||
current_origin.as_ref(),
|
||||
redirects,
|
||||
);
|
||||
|
||||
let header_map = if should_send_auth {
|
||||
metadata_headers(headers.as_deref(), cookies.as_deref(), send_cookies)
|
||||
let scoped_cookie = if send_cookies {
|
||||
cookie_scope_for_url(¤t_url, cookie_scopes.as_deref())
|
||||
} else {
|
||||
reqwest::header::HeaderMap::new()
|
||||
None
|
||||
};
|
||||
let cookie_header = if same_origin_credentials {
|
||||
scoped_cookie.or(cookies.as_deref())
|
||||
} else {
|
||||
scoped_cookie
|
||||
};
|
||||
let header_map = if same_origin_credentials {
|
||||
metadata_headers(headers.as_deref(), cookie_header, send_cookies)
|
||||
} else {
|
||||
// A cross-origin redirect may receive only the cookie scope that
|
||||
// explicitly matches its host. Never forward captured headers or
|
||||
// a source-host Cookie header to an unrelated redirect target.
|
||||
metadata_headers(None, cookie_header, send_cookies)
|
||||
};
|
||||
builder = builder.default_headers(header_map);
|
||||
|
||||
@@ -1677,7 +1755,7 @@ async fn fetch_metadata(
|
||||
let mut get_req = client
|
||||
.get(&request_url)
|
||||
.header(reqwest::header::RANGE, "bytes=0-0");
|
||||
if should_send_auth {
|
||||
if same_origin_credentials {
|
||||
if let Some(ref user) = username {
|
||||
if !user.is_empty() {
|
||||
get_req = get_req.basic_auth(user, password.as_deref());
|
||||
@@ -1688,7 +1766,7 @@ async fn fetch_metadata(
|
||||
};
|
||||
|
||||
let mut head_req = client.head(&request_url);
|
||||
if should_send_auth {
|
||||
if same_origin_credentials {
|
||||
if let Some(ref user) = username {
|
||||
if !user.is_empty() {
|
||||
head_req = head_req.basic_auth(user, password.as_deref());
|
||||
@@ -1760,6 +1838,16 @@ async fn fetch_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = metadata_authentication_error(
|
||||
¤t_url,
|
||||
current_res
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if let Some(error) = metadata_response_error(current_res.status()) {
|
||||
return Err(error);
|
||||
}
|
||||
@@ -6090,7 +6178,8 @@ mod tests {
|
||||
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
|
||||
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
|
||||
media_output_template, media_progress_args, media_progress_speed,
|
||||
metadata_cookie_header_present, metadata_headers, metadata_response_error,
|
||||
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
||||
metadata_headers, metadata_response_error,
|
||||
normalize_speed_limit_for_aria2,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||
@@ -6600,7 +6689,8 @@ mod tests {
|
||||
let headers = "Cookie: oversized=secret\nReferer: https://example.com/page";
|
||||
assert!(metadata_cookie_header_present(
|
||||
Some(headers),
|
||||
Some("session=secret")
|
||||
Some("session=secret"),
|
||||
None
|
||||
));
|
||||
|
||||
let initial_headers = metadata_headers(Some(headers), Some("session=secret"), false);
|
||||
@@ -6621,6 +6711,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_cookie_scopes_select_only_the_matching_origin() {
|
||||
let scopes = vec![
|
||||
crate::extension_server::ExtensionCookieScope {
|
||||
url: "https://mail.google.com/".to_string(),
|
||||
cookies: "mail=session".to_string(),
|
||||
},
|
||||
crate::extension_server::ExtensionCookieScope {
|
||||
url: "https://accounts.google.com/".to_string(),
|
||||
cookies: "account=session".to_string(),
|
||||
},
|
||||
crate::extension_server::ExtensionCookieScope {
|
||||
url: "https://googleusercontent.com/".to_string(),
|
||||
cookies: "googleusercontent=session".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
cookie_scope_for_url("https://mail.google.com/mail/u/0/", Some(&scopes)),
|
||||
Some("mail=session")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_scope_for_url("https://accounts.google.com/v3/signin/identifier", Some(&scopes)),
|
||||
Some("account=session")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_scope_for_url("https://mail-attachment.googleusercontent.com/file", Some(&scopes)),
|
||||
Some("googleusercontent=session")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_scope_for_url("https://attacker.example/redirect", Some(&scopes)),
|
||||
None
|
||||
);
|
||||
assert!(!metadata_cookie_header_present(None, None, Some(&scopes[..0])));
|
||||
assert!(metadata_cookie_header_present(None, None, Some(&scopes)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_cookie_retry_is_limited_to_one_auth_challenge() {
|
||||
let mut send_cookies = false;
|
||||
@@ -6865,6 +6992,38 @@ mod tests {
|
||||
filename_from_url_path("https://example.com/files/a%2Fb.mkv"),
|
||||
Some("b.mkv".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
filename_from_url_path("https://accounts.google.com/v3/signin/identifier"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
filename_from_url_path("https://mail.google.com/mail/u/0/"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
filename_from_url_path("https://example.com/files/123"),
|
||||
Some("123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_rejects_google_sign_in_interstitials() {
|
||||
let message = metadata_authentication_error(
|
||||
"https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fmail.google.com%2F",
|
||||
Some("text/html; charset=UTF-8"),
|
||||
)
|
||||
.expect("Google sign-in HTML must not become a download");
|
||||
assert!(message.contains("Google sign-in page"));
|
||||
assert!(metadata_authentication_error(
|
||||
"https://accounts.google.com/drive/download/report.zip",
|
||||
Some("application/zip"),
|
||||
)
|
||||
.is_none());
|
||||
assert!(metadata_authentication_error(
|
||||
"https://downloads.example.com/signin/identifier",
|
||||
Some("text/html"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user