use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Landing {
pub requested: String,
#[serde(rename = "final")]
pub final_url: String,
pub redirected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_status: Option<u16>,
}
const AUTH_WALL_SEGMENTS: &[&str] =
&["login", "log-in", "signin", "sign-in", "sign_in", "auth", "sso"];
impl Landing {
pub fn new(requested: &str, final_url: &str, http_status: Option<u16>) -> Self {
Self {
requested: requested.to_string(),
final_url: final_url.to_string(),
redirected: is_redirect(requested, final_url),
http_status: status_or_none(http_status),
}
}
pub fn hint(&self) -> Option<String> {
if !self.redirected {
return None;
}
let segment = auth_wall_segment(&self.final_url)?;
Some(format!(
"The redirect landed on a path containing '{segment}', which often means the \
session expired. This is a guess from the URL, not a reading of the page: run \
`inspect` to see what is there, and re-authenticate if it is a login form."
))
}
pub fn attach(&self, out: &mut serde_json::Value) {
if let Some(map) = out.as_object_mut() {
map.insert(
"landed".into(),
serde_json::to_value(self).unwrap_or(serde_json::Value::Null),
);
if let Some(hint) = self.hint() {
map.entry("hint").or_insert_with(|| serde_json::json!(hint));
}
}
}
pub fn text_line(&self) -> Option<String> {
if !self.redirected {
return None;
}
let status = self
.http_status
.map_or_else(String::new, |code| format!(" (HTTP {code})"));
let mut line = format!("redirected from {}{}", self.requested, status);
if let Some(hint) = self.hint() {
line.push_str("\nhint: ");
line.push_str(&hint);
}
Some(line)
}
}
fn status_or_none(raw: Option<u16>) -> Option<u16> {
raw.filter(|code| (100..=599).contains(code))
}
pub fn is_redirect(requested: &str, final_url: &str) -> bool {
comparison_key(requested) != comparison_key(final_url)
}
fn comparison_key(url: &str) -> String {
let without_fragment = url.split_once('#').map_or(url, |(head, _)| head);
let (scheme, rest) = without_fragment
.split_once("://")
.map_or(("", without_fragment), |(scheme, rest)| (scheme, rest));
let scheme = scheme.to_ascii_lowercase();
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
let (authority, path_and_query) = rest.split_at(authority_end);
let mut authority = authority.to_ascii_lowercase();
let default_port = match scheme.as_str() {
"http" => ":80",
"https" => ":443",
_ => "",
};
if !default_port.is_empty()
&& let Some(host) = authority.strip_suffix(default_port)
{
authority = host.to_string();
}
let (path, query) = path_and_query
.split_once('?')
.map_or((path_and_query, ""), |(path, query)| (path, query));
let path = path.strip_suffix('/').unwrap_or(path);
let query = if query.is_empty() {
String::new()
} else {
format!("?{query}")
};
format!("{scheme}://{authority}{path}{query}")
}
pub fn auth_wall_segment(url: &str) -> Option<&'static str> {
let without_fragment = url.split_once('#').map_or(url, |(head, _)| head);
let without_query = without_fragment
.split_once('?')
.map_or(without_fragment, |(head, _)| head);
let path = without_query
.split_once("://")
.map_or(without_query, |(_, rest)| {
rest.find('/').map_or("", |index| &rest[index..])
});
path.split('/')
.filter(|segment| !segment.is_empty())
.find_map(|segment| {
let stem = segment.split('.').next().unwrap_or(segment).to_ascii_lowercase();
AUTH_WALL_SEGMENTS.iter().copied().find(|token| *token == stem)
})
}
#[cfg(test)]
mod tests {
use super::{auth_wall_segment, is_redirect, Landing};
#[test]
fn same_url_is_not_a_redirect() {
assert!(!is_redirect(
"https://example.com/orders",
"https://example.com/orders"
));
}
#[test]
fn fragment_only_change_is_not_a_redirect() {
assert!(!is_redirect(
"https://example.com/docs",
"https://example.com/docs#install"
));
assert!(!is_redirect(
"https://example.com/docs#install",
"https://example.com/docs"
));
}
#[test]
fn trailing_slash_is_not_a_redirect() {
assert!(!is_redirect(
"https://example.com/orders",
"https://example.com/orders/"
));
assert!(!is_redirect("https://example.com", "https://example.com/"));
}
#[test]
fn default_port_and_host_case_are_not_a_redirect() {
assert!(!is_redirect(
"https://Example.COM:443/a",
"https://example.com/a"
));
assert!(!is_redirect("http://example.com:80/a", "http://example.com/a"));
}
#[test]
fn path_case_is_a_redirect() {
assert!(is_redirect("https://example.com/A", "https://example.com/a"));
}
#[test]
fn gained_query_is_a_redirect() {
assert!(is_redirect(
"https://app.example.com/orders",
"https://app.example.com/login?next=/orders"
));
}
#[test]
fn empty_query_is_not_a_redirect() {
assert!(!is_redirect("https://example.com/a?", "https://example.com/a"));
}
#[test]
fn scheme_upgrade_is_a_redirect() {
assert!(is_redirect("http://example.com/a", "https://example.com/a"));
}
#[test]
fn host_change_is_a_redirect() {
assert!(is_redirect("https://example.com/a", "https://www.example.com/a"));
}
#[test]
fn auth_wall_matches_whole_segments_and_stems() {
assert_eq!(auth_wall_segment("https://x.com/login"), Some("login"));
assert_eq!(auth_wall_segment("https://x.com/login.php"), Some("login"));
assert_eq!(auth_wall_segment("https://x.com/users/sign_in"), Some("sign_in"));
assert_eq!(auth_wall_segment("https://x.com/auth/realms/x"), Some("auth"));
assert_eq!(auth_wall_segment("https://x.com/sso/saml"), Some("sso"));
assert_eq!(auth_wall_segment("https://x.com/LOGIN"), Some("login"));
}
#[test]
fn auth_wall_does_not_fire_on_a_longer_word() {
assert_eq!(auth_wall_segment("https://x.com/authors/tolkien"), None);
assert_eq!(auth_wall_segment("https://x.com/ssology"), None);
assert_eq!(auth_wall_segment("https://x.com/loginformation"), None);
assert_eq!(auth_wall_segment("https://x.com/orders"), None);
}
#[test]
fn auth_wall_ignores_the_query_and_fragment() {
assert_eq!(auth_wall_segment("https://x.com/orders?next=/login"), None);
assert_eq!(auth_wall_segment("https://x.com/orders#login"), None);
}
#[test]
fn hint_fires_only_on_a_redirect_to_an_auth_wall() {
let bounced = Landing::new(
"https://app.example.com/orders",
"https://app.example.com/login?next=/orders",
Some(200),
);
assert!(bounced.redirected);
assert!(bounced.hint().is_some());
let ordinary = Landing::new(
"https://example.com/start",
"https://example.com/settled",
Some(200),
);
assert!(ordinary.redirected);
assert!(ordinary.hint().is_none());
let deliberate = Landing::new("https://x.com/login", "https://x.com/login", None);
assert!(!deliberate.redirected);
assert!(deliberate.hint().is_none());
}
#[test]
fn status_zero_is_absent_rather_than_reported() {
let landing = Landing::new("file:///tmp/a.html", "file:///tmp/a.html", Some(0));
assert!(landing.http_status.is_none());
let json = serde_json::to_value(&landing).unwrap();
assert!(json.get("http_status").is_none());
let unavailable = Landing::new("https://x.com/a", "https://x.com/a", None);
assert!(unavailable.http_status.is_none());
}
#[test]
fn serialises_final_not_final_url() {
let landing = Landing::new("https://x.com/a", "https://x.com/b", Some(200));
let json = serde_json::to_value(&landing).unwrap();
assert_eq!(json["requested"], "https://x.com/a");
assert_eq!(json["final"], "https://x.com/b");
assert_eq!(json["redirected"], true);
assert_eq!(json["http_status"], 200);
assert!(json.get("final_url").is_none(), "must not emit a `final_url` key");
}
#[test]
fn attach_does_not_overwrite_an_existing_hint() {
let landing = Landing::new("https://x.com/a", "https://x.com/login", Some(200));
let mut out = serde_json::json!({"ok": true, "hint": "something more specific"});
landing.attach(&mut out);
assert_eq!(out["hint"], "something more specific");
assert_eq!(out["landed"]["final"], "https://x.com/login");
}
#[test]
fn text_line_is_silent_when_nothing_moved() {
let straight = Landing::new("https://x.com/a", "https://x.com/a", Some(200));
assert!(straight.text_line().is_none());
let moved = Landing::new("https://x.com/a", "https://x.com/b", Some(301));
let line = moved.text_line().unwrap();
assert!(line.contains("redirected from https://x.com/a"), "got {line:?}");
assert!(line.contains("HTTP 301"), "got {line:?}");
}
}