use std::path::{Path, PathBuf};
use crate::real_path;
pub fn canonical_index(location: &str) -> Result<String, String> {
let location = location.trim();
let lower = location.to_ascii_lowercase();
if lower.starts_with("https://") || lower.starts_with("http://") {
return canonical_http(location);
}
if lower.starts_with("file:") {
let path = file_url_path(&location[5..])?;
return Ok(canonical_local(&path));
}
let path = Path::new(location);
if path.is_absolute() {
return Ok(canonical_local(path));
}
Err(format!(
"`{location}` is not an index: give an https:// URL, a file: URL or an absolute path"
))
}
pub fn local_index_path(canonical: &str) -> Option<PathBuf> {
let rest = canonical.strip_prefix("file://")?.trim_end_matches('/');
Some(PathBuf::from(strip_drive_slash(rest)))
}
pub fn index_dir_name(canonical: &str) -> String {
let host = match canonical.split_once("://") {
Some((scheme, rest)) if scheme != "file" => {
let authority = rest.split('/').next().unwrap_or("");
split_port(authority)
.0
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '.' || *c == '-')
.collect()
}
_ => "local".to_string(),
};
format!("{host}-{:08x}", crc32fast::hash(canonical.as_bytes()))
}
fn canonical_http(url: &str) -> Result<String, String> {
let (scheme, rest) = url
.split_once("://")
.expect("the caller checked the scheme");
let scheme = scheme.to_ascii_lowercase();
if rest.contains(['?', '#']) {
return Err(format!("`{url}`: an index URL has no query or fragment"));
}
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
if authority.is_empty() || authority.contains('@') {
return Err(format!(
"`{url}`: an index URL needs a host, and no user name"
));
}
let authority = authority.to_ascii_lowercase();
let (host, port) = split_port(&authority);
if scheme == "http" && host != "127.0.0.1" && host != "[::1]" {
return Err(format!(
"`{url}`: plain http:// is allowed only for 127.0.0.1 and [::1]; use https://"
));
}
let default = if scheme == "https" { "443" } else { "80" };
let authority = match port {
Some(port) if port != default => format!("{host}:{port}"),
_ => host.to_string(),
};
Ok(format!(
"{scheme}://{authority}{}/",
path.trim_end_matches('/')
))
}
fn split_port(authority: &str) -> (&str, Option<&str>) {
if let Some(end) = authority.rfind(']') {
return match authority[end + 1..].strip_prefix(':') {
Some(port) => (&authority[..=end], Some(port)),
None => (authority, None),
};
}
match authority.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
}
}
fn file_url_path(rest: &str) -> Result<PathBuf, String> {
let path = match rest.strip_prefix("//") {
Some(after) => {
let (host, path) = match after.find('/') {
Some(i) => (&after[..i], &after[i..]),
None => (after, ""),
};
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
return Err(format!(
"file://{after}: a file: index must be a directory on this machine"
));
}
path
}
None => rest,
};
let decoded = percent_decode(path)?;
let path = strip_drive_slash(&decoded).to_string();
if !Path::new(&path).is_absolute() {
return Err(format!("file:{rest} does not name an absolute path"));
}
Ok(PathBuf::from(path))
}
fn strip_drive_slash(path: &str) -> &str {
let bytes = path.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
&path[1..]
} else {
path
}
}
fn percent_decode(text: &str) -> Result<String, String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let byte = text
.get(i + 1..i + 3)
.filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
let Some(byte) = byte else {
return Err(format!("`{text}` has a bad percent-escape"));
};
out.push(byte);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| format!("`{text}` is not UTF-8 once decoded"))
}
fn real_prefix(path: &Path) -> PathBuf {
let mut existing = path;
let mut rest = Vec::new();
while !existing.exists() {
match (existing.parent(), existing.file_name()) {
(Some(parent), Some(name)) => {
rest.push(name);
existing = parent;
}
_ => break,
}
}
let mut real = real_path(existing);
for name in rest.iter().rev() {
real.push(name);
}
real
}
fn canonical_local(path: &Path) -> String {
let mut text = real_prefix(path).to_string_lossy().replace('\\', "/");
if text.as_bytes().get(1) == Some(&b':') {
if let Some(drive) = text.get_mut(..1) {
drive.make_ascii_uppercase();
}
}
let text = text.trim_end_matches('/');
if text.starts_with('/') {
format!("file://{text}/")
} else {
format!("file:///{text}/")
}
}