use url::Url;
use crate::client::transport::retry::RetrySafety;
use crate::{ZaiError, ZaiResult, client::error::codes};
pub const MAX_REDIRECTS: u8 = 3;
pub fn follow(
current: &Url,
status: u16,
location: &str,
safety: RetrySafety,
method: &str,
hops: u8,
) -> ZaiResult<Option<Url>> {
if hops >= MAX_REDIRECTS {
return Err(redirect_err("redirect limit (3) exceeded"));
}
if safety == RetrySafety::NonIdempotent {
return Ok(None);
}
if !is_redirect_status(status) {
return Ok(None);
}
let allowed = match method {
"GET" | "HEAD" => matches!(status, 301 | 302 | 303 | 307 | 308),
_ => matches!(status, 307 | 308),
};
if !allowed {
return Ok(None);
}
let target = current
.join(location)
.map_err(|e| redirect_err(&format!("invalid Location: {e}")))?;
if !target.username().is_empty() || target.password().is_some() {
return Err(redirect_err("redirect target must not contain userinfo"));
}
if target.fragment().is_some() {
return Err(redirect_err("redirect target must not contain a fragment"));
}
if target.scheme() != current.scheme() {
let downgraded = matches!(
(current.scheme(), target.scheme()),
("https", "http") | ("wss", "ws")
);
if downgraded {
return Err(redirect_err("TLS downgrade refused"));
}
return Err(redirect_err("cross-origin redirect refused"));
}
if target.host_str() != current.host_str() || target.port() != current.port() {
return Err(redirect_err("cross-origin redirect refused"));
}
Ok(Some(target))
}
fn is_redirect_status(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
}
fn redirect_err(msg: &str) -> ZaiError {
ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: format!("redirect: {msg}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn nonidempotent_never_follows() {
let cur = url("https://open.bigmodel.cn/api/paas/v4/x");
let r = follow(
&cur,
302,
"/api/paas/v4/y",
RetrySafety::NonIdempotent,
"POST",
0,
)
.unwrap();
assert!(r.is_none());
}
#[test]
fn get_follows_301_to_same_origin() {
let cur = url("https://open.bigmodel.cn/a");
let r = follow(&cur, 301, "/b", RetrySafety::Idempotent, "GET", 0).unwrap();
assert_eq!(r.unwrap().as_str(), "https://open.bigmodel.cn/b");
}
#[test]
fn put_only_follows_307_308() {
let cur = url("https://open.bigmodel.cn/a");
assert!(
follow(&cur, 302, "/b", RetrySafety::Idempotent, "PUT", 0)
.unwrap()
.is_none()
);
let r = follow(&cur, 307, "/b", RetrySafety::Idempotent, "PUT", 0).unwrap();
assert_eq!(r.unwrap().as_str(), "https://open.bigmodel.cn/b");
}
#[test]
fn cross_origin_refused() {
let cur = url("https://open.bigmodel.cn/a");
let err = follow(
&cur,
302,
"https://evil.example.com/b",
RetrySafety::Idempotent,
"GET",
0,
)
.unwrap_err();
assert!(format!("{err}").contains("cross-origin"));
}
#[test]
fn tls_downgrade_refused() {
let cur = url("https://open.bigmodel.cn/a");
let err = follow(
&cur,
302,
"http://open.bigmodel.cn/b",
RetrySafety::Idempotent,
"GET",
0,
)
.unwrap_err();
assert!(
format!("{err}").contains("TLS downgrade") || format!("{err}").contains("cross-origin")
);
}
#[test]
fn redirect_limit_enforced() {
let cur = url("https://open.bigmodel.cn/a");
assert!(
follow(
&cur,
302,
"/b",
RetrySafety::Idempotent,
"GET",
MAX_REDIRECTS
)
.is_err()
);
}
}