use std::path::Path;
use super::serving::{contained_file, Resolved};
pub(crate) fn resolve(root: &Path, relative: &str) -> Option<Resolved> {
let components: Vec<&str> = relative.split('/').filter(|c| !c.is_empty()).collect();
let mut current = root.to_path_buf();
for (index, component) in components.iter().enumerate() {
current.push(component);
let meta = std::fs::symlink_metadata(¤t).ok()?;
if meta.file_type().is_symlink() {
let target = std::fs::read_link(¤t).ok()?;
let suffix = components[index + 1..].join("/");
return location_value(&target, &suffix).map(Resolved::Redirect);
}
}
contained_file(root, relative).map(Resolved::File)
}
pub(crate) fn location_value(target: &Path, suffix: &str) -> Option<String> {
let mut location = target.to_str()?.to_string();
if !suffix.is_empty() {
if !location.ends_with('/') {
location.push('/');
}
location.push_str(suffix);
}
if location.is_empty() || location.bytes().any(|b| b < 0x20 || b == 0x7f) {
return None;
}
Some(location)
}
pub(crate) fn redirect_response(
location: &str,
permanent: bool,
) -> Option<axum::response::Response> {
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::IntoResponse;
let value = HeaderValue::try_from(location).ok()?;
let status = if permanent {
StatusCode::PERMANENT_REDIRECT
} else {
StatusCode::TEMPORARY_REDIRECT
};
Some((status, [(header::LOCATION, value)]).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn resolve_redirects_through_a_directory_link_with_the_suffix_joined() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(root.join("theme")).unwrap();
std::fs::write(root.join("theme/site.css"), b"x").unwrap();
symlink(Path::new("theme"), root.join("styles")).unwrap();
match resolve(&root, "styles/site.css") {
Some(Resolved::Redirect(location)) => assert_eq!(location, "theme/site.css"),
other => panic!("expected a redirect, got {:?}", other.is_some()),
}
match resolve(&root, "theme/site.css") {
Some(Resolved::File(real)) => assert!(real.ends_with("theme/site.css")),
other => panic!("expected the plain file, got {:?}", other.is_some()),
}
}
#[test]
fn location_value_joins_and_sanitizes() {
assert_eq!(
location_value(Path::new("theme"), "sub/site.css").as_deref(),
Some("theme/sub/site.css")
);
assert_eq!(
location_value(Path::new("theme/"), "site.css").as_deref(),
Some("theme/site.css"),
"no doubled slash"
);
assert_eq!(
location_value(Path::new("/etc/passwd"), "").as_deref(),
Some("/etc/passwd"),
"absolute targets are literal URL paths - never opened"
);
assert!(location_value(Path::new("x\r\nSet-Cookie: a=b"), "").is_none());
assert!(location_value(Path::new(""), "").is_none());
}
#[test]
fn redirect_response_sets_status_and_location_with_an_empty_body() {
let temporary = redirect_response("target.js", false).unwrap();
assert_eq!(temporary.status(), 307);
assert_eq!(temporary.headers()["location"], "target.js");
let permanent = redirect_response("target.js", true).unwrap();
assert_eq!(permanent.status(), 308);
assert!(redirect_response("bad\nvalue", true).is_none());
}
}